diff --git a/docs/en_US/directory_dialog.rst b/docs/en_US/directory_dialog.rst new file mode 100644 index 000000000..f93b49a3d --- /dev/null +++ b/docs/en_US/directory_dialog.rst @@ -0,0 +1,87 @@ +.. _directory_dialog: + +************************* +`Directory Dialog`:index: +************************* + +Use the Directory dialog to Create an alias for a file system directory path. +To create directories, you must have the CREATE ANY DIRECTORY system privilege. +When you create a directory, you are automatically granted READ and WRITE privileges +on the directory, and you can grant READ and WRITE privileges to other users and roles. +The superuser can also grant these privileges to other users and roles. + +Please note that directories are supported when connected to EDB Postgres Advanced Server. +For more information about using directories, please see the EDB Postgres Advanced Server Guide, available at: + + https://www.enterprisedb.com/docs/epas/latest/epas_compat_sql/ + + +The *Directory* dialog organizes the definition of a directory through the +following tabs: *General*, *Definition*, *Security*, and *SQL*. +The *SQL* tab displays the SQL code generated by dialog selections. + +.. image:: images/directory_general.png + :alt: Directory general tab + :align: center + +Use the fields on the *General* tab to specify directory attributes: + +* Use the *Name* field to add a directory alias name. This name will be displayed in the object explorer. +* Select the owner of the directory from the drop-down listbox in the *Owner* + field. + +Click the *Definition* tab to continue. + +.. image:: images/directory_definition.png + :alt: Directory dialog definition tab + :align: center + +* Use the *Location* field to specify a fully qualified directory path represented + by the alias name. The CREATE DIRECTORY command doesn't create the operating system directory. + The physical directory must be created independently using operating system commands. + +Click the *Security* tab to continue. + +.. image:: images/directory_security.png + :alt: Directory dialog security tab + :align: center + +NOTE:- This *Security* tab will be only available for EPAS 17. + +Use the *Security* tab to assign privileges for the directory. + +Use the *Privileges* panel to assign security privileges. Click the *Add* icon +(+) to assign a set of privileges: + +* Select the name of the role from the drop-down listbox in the *Grantee* field. +* The current user, who is the default grantor for granting the privilege, is displayed in the *Grantor* field. +* Click inside the *Privileges* field. Check the boxes to the left of one or + more privileges to grant the selected privileges to the specified user. + +Click the *Add* icon to assign additional sets of privileges; to discard a +privilege, click the trash icon to the left of the row and confirm deletion in +the *Delete Row* popup. + +Click the *SQL* tab to continue. + +Your entries in the *Directory* dialog generate a SQL command (see an example +below). Use the *SQL* tab for review; revisit or switch tabs to make any changes +to the SQL command. + +Example +******* + +The following is an example of the sql command generated by user selections in +the *Directory* dialog: + +.. image:: images/directory_sql.png + :alt: Directory dialog sql tab + :align: center + +The example shown demonstrates creating a directory named *test1*. It has a +*location* value equal to */home/test_dir*. + +* Click the *Info* button (i) to access online help. +* Click the *Save* button to save work. +* Click the *Close* button to exit without saving work. +* Click the *Reset* button to restore configuration parameters. diff --git a/docs/en_US/images/directory_definition.png b/docs/en_US/images/directory_definition.png new file mode 100644 index 000000000..9fe629dee Binary files /dev/null and b/docs/en_US/images/directory_definition.png differ diff --git a/docs/en_US/images/directory_general.png b/docs/en_US/images/directory_general.png new file mode 100644 index 000000000..4941aa283 Binary files /dev/null and b/docs/en_US/images/directory_general.png differ diff --git a/docs/en_US/images/directory_security.png b/docs/en_US/images/directory_security.png new file mode 100644 index 000000000..d9856b5c6 Binary files /dev/null and b/docs/en_US/images/directory_security.png differ diff --git a/docs/en_US/images/directory_sql.png b/docs/en_US/images/directory_sql.png new file mode 100644 index 000000000..ca4d0d794 Binary files /dev/null and b/docs/en_US/images/directory_sql.png differ diff --git a/docs/en_US/managing_cluster_objects.rst b/docs/en_US/managing_cluster_objects.rst index 55ca3c5ec..bbfea3f4d 100644 --- a/docs/en_US/managing_cluster_objects.rst +++ b/docs/en_US/managing_cluster_objects.rst @@ -20,4 +20,5 @@ database, right-click on the *Databases* node, and select *Create Database...* tablespace_dialog replica_nodes_dialog pgd_replication_group_dialog - role_reassign_dialog \ No newline at end of file + role_reassign_dialog + directory_dialog \ No newline at end of file diff --git a/docs/en_US/resource_group_dialog.rst b/docs/en_US/resource_group_dialog.rst index 84251948e..1d38de373 100644 --- a/docs/en_US/resource_group_dialog.rst +++ b/docs/en_US/resource_group_dialog.rst @@ -13,7 +13,7 @@ connected to EDB Postgres Advanced Server; for more information about using resource groups, please see the EDB Postgres Advanced Server Guide, available at: - http://www.enterprisedb.com/ + https://www.enterprisedb.com/docs/epas/latest/epas_compat_sql/ Fields used to create a resource group are located on the *General* tab. The *SQL* tab displays the SQL code generated by your selections on the *Resource diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 129da12a6..de333bc27 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -331,6 +331,9 @@ class ServerModule(sg.ServerGroupPluginModule): from .tablespaces import blueprint as module self.submodules.append(module) + from .directories import blueprint as module + self.submodules.append(module) + from .replica_nodes import blueprint as module self.submodules.append(module) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/tests/table_test_data.json b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/tests/table_test_data.json index b0777413a..65abbd928 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/tests/table_test_data.json +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/tests/table_test_data.json @@ -5,7 +5,7 @@ "is_positive_test": true, "inventory_data": { "server_min_version": 100000, - "skip_msg": "Identity columns are not supported by PPAS/PG 10.0 and below." + "skip_msg": "Identity columns are not supported by EPAS/PG 10.0 and below." }, "test_data": { "description": "Create Table API Test", @@ -111,7 +111,7 @@ "is_positive_test": true, "inventory_data": { "server_min_version": 110000, - "skip_msg": "Hash Partition are not supported by PPAS/PG 11.0 and below." + "skip_msg": "Hash Partition are not supported by EPAS/PG 11.0 and below." }, "test_data": { "is_partitioned": true, @@ -150,7 +150,7 @@ "is_positive_test": true, "inventory_data": { "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below." + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below." }, "test_data": { "is_partitioned": true, @@ -204,7 +204,7 @@ "is_positive_test": true, "inventory_data": { "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below." + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below." }, "test_data": { "is_partitioned": true, @@ -241,7 +241,7 @@ "is_positive_test": true, "inventory_data": { "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below." + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below." }, "test_data": { "is_partitioned": true, @@ -297,7 +297,7 @@ "is_positive_test": true, "inventory_data": { "server_min_version": 110000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below." + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below." }, "test_data": { "is_partitioned": true, @@ -344,7 +344,7 @@ "is_positive_test": true, "inventory_data": { "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below." + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below." }, "test_data": { "is_partitioned": true, @@ -383,7 +383,7 @@ "is_positive_test": true, "inventory_data": { "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below." + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below." }, "test_data": { "is_partitioned": true, @@ -434,7 +434,7 @@ "is_positive_test": true, "inventory_data": { "server_min_version": 100000, - "skip_msg": "Identity columns are not supported by PPAS/PG 10.0 and below." + "skip_msg": "Identity columns are not supported by EPAS/PG 10.0 and below." }, "test_data": { "table_name": "abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklmnopqrstuvwxyz123", @@ -493,7 +493,7 @@ "is_positive_test": false, "inventory_data": { "server_min_version": 100000, - "skip_msg": "Identity columns are not supported by PPAS/PG 10.0 and below." + "skip_msg": "Identity columns are not supported by EPAS/PG 10.0 and below." }, "test_data": { "table_name": "", @@ -553,7 +553,7 @@ "is_positive_test": false, "inventory_data": { "server_min_version": 100000, - "skip_msg": "Identity columns are not supported by PPAS/PG 10.0 and below." + "skip_msg": "Identity columns are not supported by EPAS/PG 10.0 and below." }, "test_data": { "description": "Create Table API Test", @@ -1164,7 +1164,7 @@ "inventory_data": { "is_partition": true, "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below.", + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below.", "partition_type": "range", "mode": "create" }, @@ -1184,7 +1184,7 @@ "inventory_data": { "is_partition": true, "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below.", + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below.", "partition_type": "range", "mode": "multilevel" }, @@ -1204,7 +1204,7 @@ "inventory_data": { "is_partition": true, "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below.", + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below.", "partition_type": "list", "mode": "create" }, @@ -1224,7 +1224,7 @@ "inventory_data": { "is_partition": true, "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below.", + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below.", "partition_type": "list", "mode": "multilevel" }, @@ -1244,7 +1244,7 @@ "inventory_data": { "is_partition": true, "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below.", + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below.", "partition_type": "range", "mode": "detach" }, @@ -1264,7 +1264,7 @@ "inventory_data": { "is_partition": true, "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below.", + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below.", "partition_type": "list", "mode": "detach" }, @@ -1284,7 +1284,7 @@ "inventory_data": { "is_partition": true, "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below.", + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below.", "partition_type": "range", "mode": "attach" }, @@ -1304,7 +1304,7 @@ "inventory_data": { "is_partition": true, "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below.", + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below.", "partition_type": "list", "mode": "attach" }, @@ -2145,7 +2145,7 @@ "is_positive_test": true, "inventory_data": { "server_min_version": 100000, - "skip_msg": "Partitioned table are not supported by PPAS/PG 10.0 and below." + "skip_msg": "Partitioned table are not supported by EPAS/PG 10.0 and below." }, "test_data": {}, "mocking_required": false, diff --git a/web/pgadmin/browser/server_groups/servers/directories/__init__.py b/web/pgadmin/browser/server_groups/servers/directories/__init__.py new file mode 100644 index 000000000..8a0c6827e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/__init__.py @@ -0,0 +1,587 @@ +# -*- coding: utf-8 -*- + +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + + +"""Implements Directories for EPAS 13 and above""" + +import json +import re +from functools import wraps + +from pgadmin.browser.server_groups import servers +from flask import render_template, request, jsonify, current_app +from flask_babel import gettext +from pgadmin.browser.collection import CollectionNodeModule +from pgadmin.browser.server_groups.servers.utils import parse_priv_from_db, \ + parse_priv_to_db +from pgadmin.browser.utils import PGChildNodeView +from pgadmin.utils.ajax import make_json_response, \ + make_response as ajax_response, internal_server_error, gone +from pgadmin.utils.ajax import precondition_required +from pgadmin.utils.driver import get_driver +from config import PG_DEFAULT_DRIVER + + +class DirectoryModule(CollectionNodeModule): + """ + Module for managing directories. + """ + _NODE_TYPE = 'directory' + _COLLECTION_LABEL = gettext("Directories") + + def __init__(self, import_name, **kwargs): + super().__init__(import_name, **kwargs) + + self.min_ver = 130000 + self.max_ver = None + self.server_type = ['ppas'] + + def get_nodes(self, gid, sid): + """ + Generate the collection node + """ + yield self.generate_browser_collection_node(sid) + + @property + def script_load(self): + """ + Load the module script for server, when any of the server-group node is + initialized. + """ + return servers.ServerModule.node_type + + @property + def module_use_template_javascript(self): + """ + Returns whether Jinja2 template is used for generating the javascript + module. + """ + return False + + @property + def node_inode(self): + return False + + +# Register the module as a Blueprint +blueprint = DirectoryModule(__name__) + + +class DirectoryView(PGChildNodeView): + node_type = blueprint.node_type + + parent_ids = [ + {'type': 'int', 'id': 'gid'}, + {'type': 'int', 'id': 'sid'} + ] + ids = [ + {'type': 'int', 'id': 'dr_id'} + ] + + operations = dict({ + 'obj': [ + {'get': 'properties', 'delete': 'delete', 'put': 'update'}, + {'get': 'list', 'post': 'create', 'delete': 'delete'} + ], + 'nodes': [{'get': 'node'}, {'get': 'nodes'}], + 'children': [{'get': 'children'}], + 'sql': [{'get': 'sql'}], + 'msql': [{'get': 'msql'}, {'get': 'msql'}], + }) + + def check_precondition(f): + """ + This function will behave as a decorator which will checks + database connection before running view, it will also attaches + manager,conn & template_path properties to self + """ + + @wraps(f) + def wrap(*args, **kwargs): + # Here args[0] will hold self & kwargs will hold gid,sid,dr_id + self = args[0] + self.manager = get_driver( + PG_DEFAULT_DRIVER + ).connection_manager( + kwargs['sid'] + ) + self.conn = self.manager.connection() + self.datistemplate = False + if ( + self.manager.db_info is not None and + self.manager.did in self.manager.db_info and + 'datistemplate' in self.manager.db_info[self.manager.did] + ): + self.datistemplate = self.manager.db_info[ + self.manager.did]['datistemplate'] + + # If DB not connected then return error to browser + if not self.conn.connected(): + current_app.logger.warning( + "Connection to the server has been lost." + ) + return precondition_required( + gettext( + "Connection to the server has been lost." + ) + ) + + self.template_path = 'directories/sql/#{0}#'.format( + self.manager.version + ) + current_app.logger.debug( + "Using the template path: %s", self.template_path + ) + # Allowed ACL on directory + self.acl = ['W', 'R'] + + return f(*args, **kwargs) + + return wrap + + @check_precondition + def list(self, gid, sid): + SQL = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL]), + conn=self.conn + ) + status, res = self.conn.execute_dict(SQL) + + if not status: + return internal_server_error(errormsg=res) + return ajax_response( + response=res['rows'], + status=200 + ) + + @check_precondition + def node(self, gid, sid, dr_id): + SQL = render_template( + "/".join([self.template_path, self._NODES_SQL]), + dr_id=dr_id, conn=self.conn + ) + status, rset = self.conn.execute_2darray(SQL) + if not status: + return internal_server_error(errormsg=rset) + + if len(rset['rows']) == 0: + return gone(gettext("""Could not find the directory.""")) + + res = self.blueprint.generate_browser_node( + rset['rows'][0]['oid'], + sid, + rset['rows'][0]['name'], + icon="icon-directory" + ) + + return make_json_response( + data=res, + status=200 + ) + + @check_precondition + def nodes(self, gid, sid, dr_id=None): + res = [] + SQL = render_template( + "/".join([self.template_path, self._NODES_SQL]), + dr_id=dr_id, conn=self.conn + ) + status, rset = self.conn.execute_2darray(SQL) + if not status: + return internal_server_error(errormsg=rset) + + for row in rset['rows']: + res.append( + self.blueprint.generate_browser_node( + row['oid'], + sid, + row['name'], + icon="icon-directory", + )) + + return make_json_response( + data=res, + status=200 + ) + + def _formatter(self, data, dr_id=None): + """ + It will return formatted output of collections + """ + # We need to parse & convert ACL coming from database to json format + SQL = render_template( + "/".join([self.template_path, self._ACL_SQL]), + dr_id=dr_id, conn=self.conn + ) + status, acl = self.conn.execute_dict(SQL) + if not status: + return internal_server_error(errormsg=acl) + + # We will set get privileges from acl sql so we don't need + # it from properties sql + data['diracl'] = [] + + for row in acl['rows']: + priv = parse_priv_from_db(row) + if row['deftype'] in data: + data[row['deftype']].append(priv) + else: + data[row['deftype']] = [priv] + + return data + + @check_precondition + def properties(self, gid, sid, dr_id): + SQL = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL]), + dr_id=dr_id, conn=self.conn + ) + status, res = self.conn.execute_dict(SQL) + if not status: + return internal_server_error(errormsg=res) + + if len(res['rows']) == 0: + return gone( + gettext("""Could not find the directory information.""") + ) + + # Making copy of output for future use + copy_data = dict(res['rows'][0]) + copy_data = self._formatter(copy_data, dr_id) + + return ajax_response( + response=copy_data, + status=200 + ) + + @check_precondition + def create(self, gid, sid): + """ + This function will create the new directory object. + """ + required_args = { + 'name': 'Name', + 'path': 'Location' + } + + data = request.form if request.form else json.loads( + request.data + ) + + for arg in required_args: + if arg not in data: + return make_json_response( + status=410, + success=0, + errormsg=gettext( + "Could not find the required parameter ({})." + ).format(arg) + ) + + # To format privileges coming from client + if 'diracl' in data: + data['diracl'] = parse_priv_to_db(data['diracl'], self.acl) + + try: + SQL = render_template( + "/".join([self.template_path, self._CREATE_SQL]), + data=data, conn=self.conn + ) + + status, res = self.conn.execute_scalar(SQL) + + if not status: + return internal_server_error(errormsg=res) + + # To fetch the oid of newly created directory + SQL = render_template( + "/".join([self.template_path, self._ALTER_SQL]), + directory=data['name'], conn=self.conn + ) + + status, dr_id = self.conn.execute_scalar(SQL) + + if not status: + return internal_server_error(errormsg=dr_id) + + SQL = render_template( + "/".join([self.template_path, self._ALTER_SQL]), + data=data, conn=self.conn + ) + + # Checking if we are not executing empty query + if SQL and SQL.strip('\n') and SQL.strip(' '): + status, res = self.conn.execute_scalar(SQL) + if not status: + return jsonify( + node=self.blueprint.generate_browser_node( + dr_id, + sid, + data['name'], + icon="icon-directory" + ), + success=0, + errormsg=gettext( + 'Directory created successfully.' + ), + info=gettext( + res + ) + ) + + return jsonify( + node=self.blueprint.generate_browser_node( + dr_id, + sid, + data['name'], + icon="icon-directory", + ) + ) + except Exception as e: + current_app.logger.exception(e) + return internal_server_error(errormsg=str(e)) + + @check_precondition + def update(self, gid, sid, dr_id): + """ + This function will update directory object + """ + data = request.form if request.form else json.loads( + request.data + ) + + try: + SQL, name = self.get_sql(gid, sid, data, dr_id) + # Most probably this is due to error + if not isinstance(SQL, str): + return SQL + + SQL = SQL.strip('\n').strip(' ') + status, res = self.conn.execute_scalar(SQL) + if not status: + return internal_server_error(errormsg=res) + + return jsonify( + node=self.blueprint.generate_browser_node( + dr_id, + sid, + name, + icon="icon-%s" % self.node_type, + ) + ) + except Exception as e: + current_app.logger.exception(e) + return internal_server_error(errormsg=str(e)) + + @check_precondition + def delete(self, gid, sid, dr_id=None): + """ + This function will drop the directory object + """ + if dr_id is None: + data = request.form if request.form else json.loads( + request.data + ) + else: + data = {'ids': [dr_id]} + + try: + for dr_id in data['ids']: + SQL = render_template( + "/".join([self.template_path, self._NODES_SQL]), + dr_id=dr_id, conn=self.conn + ) + # Get name for directory from dr_id + status, rset = self.conn.execute_dict(SQL) + + if not status: + return internal_server_error(errormsg=rset) + + if not rset['rows']: + return make_json_response( + success=0, + errormsg=gettext( + 'Error: Object not found.' + ), + info=gettext( + 'The specified directory could not be found.\n' + ) + ) + + # drop directory + SQL = render_template( + "/".join([self.template_path, self._DELETE_SQL]), + dr_name=(rset['rows'][0])['name'], conn=self.conn + ) + + status, res = self.conn.execute_scalar(SQL) + if not status: + return internal_server_error(errormsg=res) + + return make_json_response( + success=1, + info=gettext("Directory dropped") + ) + + except Exception as e: + current_app.logger.exception(e) + return internal_server_error(errormsg=str(e)) + + @check_precondition + def msql(self, gid, sid, dr_id=None): + """ + This function to return modified SQL + """ + data = dict() + for k, v in request.args.items(): + try: + data[k] = json.loads(v) + except ValueError: + data[k] = v + + sql, _ = self.get_sql(gid, sid, data, dr_id) + # Most probably this is due to error + if not isinstance(sql, str): + return sql + + sql = sql.strip('\n').strip(' ') + if sql == '': + sql = "--modified SQL" + return make_json_response( + data=sql, + status=200 + ) + + def _format_privilege_data(self, data): + for key in ['diracl']: + if key in data and data[key] is not None: + if 'added' in data[key]: + data[key]['added'] = parse_priv_to_db( + data[key]['added'], self.acl + ) + if 'changed' in data[key]: + data[key]['changed'] = parse_priv_to_db( + data[key]['changed'], self.acl + ) + if 'deleted' in data[key]: + data[key]['deleted'] = parse_priv_to_db( + data[key]['deleted'], self.acl + ) + + def get_sql(self, gid, sid, data, dr_id=None): + """ + This function will generate sql from model/properties data + """ + required_args = [ + 'name' + ] + + if dr_id is not None: + SQL = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL]), + dr_id=dr_id, conn=self.conn + ) + status, res = self.conn.execute_dict(SQL) + if not status: + return internal_server_error(errormsg=res) + + if len(res['rows']) == 0: + return gone( + gettext("Could not find the directory on the server.") + ) + + # Making copy of output for further processing + old_data = dict(res['rows'][0]) + old_data = self._formatter(old_data, dr_id) + + # To format privileges data coming from client + self._format_privilege_data(data) + + # If name is not present with in update data then copy it + # from old data + for arg in required_args: + if arg not in data: + data[arg] = old_data[arg] + + SQL = render_template( + "/".join([self.template_path, self._UPDATE_SQL]), + data=data, o_data=old_data, conn=self.conn + ) + else: + # To format privileges coming from client + if 'diracl' in data: + data['diracl'] = parse_priv_to_db(data['diracl'], self.acl) + # If the request for new object which do not have dr_id + SQL = render_template( + "/".join([self.template_path, self._CREATE_SQL]), + data=data, conn=self.conn + ) + SQL += "\n" + SQL += render_template( + "/".join([self.template_path, self._ALTER_SQL]), + data=data, conn=self.conn + ) + SQL = re.sub('\n{2,}', '\n\n', SQL) + return SQL, data['name'] if 'name' in data else old_data['name'] + + @check_precondition + def sql(self, gid, sid, dr_id): + """ + This function will generate sql for sql panel + """ + SQL = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL]), + dr_id=dr_id, conn=self.conn + ) + status, res = self.conn.execute_dict(SQL) + if not status: + return internal_server_error(errormsg=res) + + if len(res['rows']) == 0: + return gone( + gettext("Could not find the directory on the server.") + ) + # Making copy of output for future use + old_data = dict(res['rows'][0]) + + old_data = self._formatter(old_data, dr_id) + + # To format privileges + if 'diracl' in old_data: + old_data['diracl'] = parse_priv_to_db(old_data['diracl'], self.acl) + + SQL = '' + # We are not showing create sql for system directory. + if not old_data['name'].startswith('pg_'): + SQL = render_template( + "/".join([self.template_path, self._CREATE_SQL]), + data=old_data, conn=self.conn + ) + SQL += "\n" + SQL += render_template( + "/".join([self.template_path, self._ALTER_SQL]), + data=old_data, conn=self.conn + ) + + sql_header = """ +-- Directory: {0} + +-- DROP DIRECTORY IF EXISTS {0}; + +""".format(old_data['name']) + + SQL = sql_header + SQL + SQL = re.sub('\n{2,}', '\n\n', SQL) + return ajax_response(response=SQL.strip('\n')) + + +# Register the view with the blueprint +DirectoryView.register_node_view(blueprint) diff --git a/web/pgadmin/browser/server_groups/servers/directories/static/img/coll-directory.svg b/web/pgadmin/browser/server_groups/servers/directories/static/img/coll-directory.svg new file mode 100644 index 000000000..b4c6aae9c --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/static/img/coll-directory.svg @@ -0,0 +1 @@ +coll-tablespace \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/directories/static/img/directory.svg b/web/pgadmin/browser/server_groups/servers/directories/static/img/directory.svg new file mode 100644 index 000000000..cf50e8645 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/static/img/directory.svg @@ -0,0 +1 @@ +tablespace \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/directories/static/js/directory.js b/web/pgadmin/browser/server_groups/servers/directories/static/js/directory.js new file mode 100644 index 000000000..a213c0495 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/static/js/directory.js @@ -0,0 +1,100 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { getNodeListByName } from '../../../../../static/js/node_ajax'; +import { getNodePrivilegeRoleSchema } from '../../../static/js/privilege.ui'; +import DirectorySchema from './directory.ui'; + +define('pgadmin.node.directory', [ + 'sources/gettext', 'sources/url_for', + 'pgadmin.browser', 'pgadmin.browser.collection', +], function( + gettext, url_for, pgBrowser +) { + + if (!pgBrowser.Nodes['coll-directory']) { + pgBrowser.Nodes['coll-directory'] = + pgBrowser.Collection.extend({ + node: 'directory', + label: gettext('Directories'), + type: 'coll-directory', + columns: ['name', 'diruser'], + canDrop: true, + canDropCascade: false, + }); + } + if (!pgBrowser.Nodes['directory']) { + pgBrowser.Nodes['directory'] = pgBrowser.Node.extend({ + parent_type: 'server', + type: 'directory', + epasHelp: true, + dialogHelp: url_for('help.static', {'filename': 'directory_dialog.html'}), + label: gettext('Directory'), + hasSQL: true, + canDrop: true, + Init: function() { + /* Avoid multiple registration of menus */ + if (this.initialized) + return; + + this.initialized = true; + + pgBrowser.add_menus([{ + name: 'create_directory_on_server', node: 'server', module: this, + applies: ['object', 'context'], callback: 'show_obj_properties', + category: 'create', priority: 4, label: gettext('Directory...'), + data: {action: 'create', + data_disabled: gettext('This option is only available on EPAS servers.')}, + /* Function is used to check the server type and version. + * Directories only supported in EPAS 13 and above. + */ + enable: function(node, item) { + let treeData = pgBrowser.tree.getTreeNodeHierarchy(item), + server = treeData['server']; + return server.connected && node.server_type === 'ppas' && + node.version >= 130000; + }, + },{ + name: 'create_directory_on_coll', node: 'coll-directory', module: this, + applies: ['object', 'context'], callback: 'show_obj_properties', + category: 'create', priority: 4, label: gettext('Directory...'), + data: {action: 'create', + data_disabled: gettext('This option is only available on EPAS servers.')}, + },{ + name: 'create_directory', node: 'directory', module: this, + applies: ['object', 'context'], callback: 'show_obj_properties', + category: 'create', priority: 4, label: gettext('Directory...'), + data: {action: 'create', + data_disabled: gettext('This option is only available on EPAS servers.')}, + }, + ]); + }, + can_create_directory: function(node, item) { + let treeData = pgBrowser.tree.getTreeNodeHierarchy(item), + server = treeData['server']; + return server.connected && server.user.is_superuser; + }, + + getSchema: function(treeNodeInfo, itemNodeData) { + return new DirectorySchema( + (privileges)=>getNodePrivilegeRoleSchema(this, treeNodeInfo, itemNodeData, privileges), + treeNodeInfo, + { + role: ()=>getNodeListByName('role', treeNodeInfo, itemNodeData), + }, + { + diruser: pgBrowser.serverInfo[treeNodeInfo.server._id].user.name, + }, + ); + }, + }); + } + + return pgBrowser.Nodes['coll-directory']; +}); diff --git a/web/pgadmin/browser/server_groups/servers/directories/static/js/directory.ui.js b/web/pgadmin/browser/server_groups/servers/directories/static/js/directory.ui.js new file mode 100644 index 000000000..c3ee1656e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/static/js/directory.ui.js @@ -0,0 +1,86 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { isEmptyString } from '../../../../../../static/js/validators'; + +export default class DirectorySchema extends BaseUISchema { + constructor(getPrivilegeRoleSchema, treeNodeInfo, fieldOptions={}, initValues={}) { + super({ + name: undefined, + owner: undefined, + path: undefined, + diracl: [], + ...initValues, + }); + this.getPrivilegeRoleSchema = getPrivilegeRoleSchema; + this.fieldOptions = { + role: [], + ...fieldOptions, + }; + this.treeNodeInfo = treeNodeInfo; + } + + get idAttribute() { + return 'oid'; + } + + get baseFields() { + let obj = this; + let fields = [ + { + id: 'name', label: gettext('Name'), cell: 'text', + type: 'text', mode: ['properties', 'create', 'edit'], + noEmpty: true, editable: false, + readonly: function(state) {return !obj.isNew(state); } + }, { + id: 'oid', label: gettext('OID'), cell: 'text', + type: 'text', mode: ['properties'], + }, { + id: 'diruser', label: gettext('Owner'), cell: 'text', + editable: false, type: 'select', options: this.fieldOptions.role, + controlProps: { allowClear: false }, isCollectionProperty: true + },{ + id: 'path', label: gettext('Location'), + noEmpty: true, editable: false, + group: gettext('Definition'), type: 'text', + mode: ['properties', 'edit','create'], + readonly: function(state) {return !obj.isNew(state); }, + }, + ]; + // Check server version before adding version-specific security fields + if (this.treeNodeInfo?.server?.version >= 170000) { + fields.push({ + id: 'diracl', label: gettext('Privileges'), type: 'collection', + group: gettext('Security'), + schema: this.getPrivilegeRoleSchema(['R','W']), + mode: ['edit', 'create'], uniqueCol : ['grantee'], + canAdd: true, canDelete: true, + }, + { + id: 'acl', label: gettext('Privileges'), type: 'text', + group: gettext('Security'), mode: ['properties'], + }, + ); + } + return fields; + } + + validate(state, setError) { + let errmsg = null; + + if (this.isNew() && isEmptyString(state.path)) { + errmsg = gettext('\'Location\' cannot be empty.'); + setError('path', errmsg); + return true; + } + return false; + } +} diff --git a/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/acl.sql b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/acl.sql new file mode 100644 index 000000000..e55694d11 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/acl.sql @@ -0,0 +1,32 @@ +{### SQL to fetch privileges for directories ###} +SELECT 'diracl' AS deftype, + COALESCE(grantee.rolname, 'PUBLIC') AS grantee, + grantor.rolname AS grantor, + ARRAY_AGG(privilege_type) AS privileges, + ARRAY_AGG(is_grantable) AS grantable +FROM ( + SELECT + acl.grantee, acl.grantor, acl.is_grantable, + CASE acl.privilege_type + WHEN 'SELECT' THEN 'R' + WHEN 'UPDATE' THEN 'W' + ELSE 'UNKNOWN' + END AS privilege_type + FROM ( + SELECT (d).grantee AS grantee, + (d).grantor AS grantor, + (d).is_grantable AS is_grantable, + (d).privilege_type AS privilege_type + FROM ( + SELECT pg_catalog.aclexplode(ed.diracl) AS d + FROM pg_catalog.edb_dir ed + {% if dr_id %} + WHERE ed.oid = {{ dr_id|qtLiteral(conn) }}::OID + {% endif %} + ) acl_exploded + ) acl +) acl_final +LEFT JOIN pg_catalog.pg_roles grantor ON (acl_final.grantor = grantor.oid) +LEFT JOIN pg_catalog.pg_roles grantee ON (acl_final.grantee = grantee.oid) +GROUP BY grantor.rolname, grantee.rolname +ORDER BY grantee; \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/alter.sql b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/alter.sql new file mode 100644 index 000000000..da888faaf --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/alter.sql @@ -0,0 +1,22 @@ +{### SQL to alter directory ###} +{% import 'macros/privilege.macros' as PRIVILEGE %} +{% if data %} +{### Owner on directory ###} +{% if data.diruser %} +ALTER DIRECTORY {{ conn|qtIdent(data.name) }} + OWNER TO {{ conn|qtIdent(data.diruser) }}; +{% endif %} + +{### ACL on directory ###} +{% if data.diracl %} +{% for priv in data.diracl %} +{{ PRIVILEGE.APPLY(conn, 'DIRECTORY', priv.grantee, data.name, priv.without_grant, priv.with_grant) }} +{% endfor %} +{% endif %} + +{% endif %} + +{# ======== The SQl Below will fetch id for given dataspace ======== #} +{% if directory %} +SELECT dir.oid FROM pg_catalog.edb_dir dir WHERE dirname = {{directory|qtLiteral(conn)}}; +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/create.sql new file mode 100644 index 000000000..4467de425 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/create.sql @@ -0,0 +1,4 @@ +{### SQL to create directory object ###} +{% if data %} +CREATE DIRECTORY {{ conn|qtIdent(data.name) }} AS {{ data.path|qtLiteral(conn) }}; +{% endif %} \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/delete.sql b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/delete.sql new file mode 100644 index 000000000..6b7cb9277 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/delete.sql @@ -0,0 +1,2 @@ +{### SQL to delete directory object ###} +DROP DIRECTORY IF EXISTS {{ conn|qtIdent(dr_name) }}; diff --git a/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/nodes.sql b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/nodes.sql new file mode 100644 index 000000000..4cfb1f649 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/nodes.sql @@ -0,0 +1,12 @@ +SELECT + dir.oid AS oid, + dirname AS name, + dirowner AS owner, + dirpath AS path +FROM + pg_catalog.edb_dir dir +{% if dr_id %} +WHERE + dir.oid={{ dr_id|qtLiteral(conn) }}::OID +{% endif %} +ORDER BY name; \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/properties.sql new file mode 100644 index 000000000..748072a31 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/properties.sql @@ -0,0 +1,13 @@ +{### SQL to fetch directory object properties ###} +SELECT + dir.oid, + dirname AS name, + pg_catalog.pg_get_userbyid(dirowner) as diruser, + dirpath AS path, + pg_catalog.array_to_string(diracl::text[], ', ') as acl +FROM + pg_catalog.edb_dir dir +{% if dr_id %} +WHERE dir.oid={{ dr_id|qtLiteral(conn) }}::OID +{% endif %} +ORDER BY name; \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/update.sql new file mode 100644 index 000000000..edb1becd5 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/templates/directories/sql/default/update.sql @@ -0,0 +1,37 @@ +{### SQL to update directory object ###} +{% import 'macros/privilege.macros' as PRIVILEGE %} +{% if data %} + +{# ==== To update directory name ==== #} +{% if data.name and data.name != o_data.name %} +ALTER DIRECTORY {{ conn|qtIdent(o_data.name) }} + RENAME TO {{ conn|qtIdent(data.name) }}; +{% endif %} + +{# ==== To update OWNER name ==== #} +{% if data.diruser %} +ALTER DIRECTORY {{ conn|qtIdent(data.name) }} OWNER TO {{ conn|qtIdent(data.diruser) }}; +{% endif %} + +{# ==== To update directory privileges ==== #} +{# Change the privileges #} +{% if data.diracl %} +{% if 'deleted' in data.diracl %} +{% for priv in data.diracl.deleted %} +{{ PRIVILEGE.RESETALL(conn, 'DIRECTORY', priv.grantee, data.name) }} +{% endfor %} +{% endif %} +{% if 'changed' in data.diracl %} +{% for priv in data.diracl.changed %} +{{ PRIVILEGE.RESETALL(conn, 'DIRECTORY', priv.grantee, data.name) }} +{{ PRIVILEGE.APPLY(conn, 'DIRECTORY', priv.grantee, data.name, priv.without_grant, priv.with_grant) }} +{% endfor %} +{% endif %} +{% if 'added' in data.diracl %} +{% for priv in data.diracl.added %} +{{ PRIVILEGE.APPLY(conn, 'DIRECTORY', priv.grantee, data.name, priv.without_grant, priv.with_grant) }} +{% endfor %} +{% endif %} + +{% endif %} +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/__init__.py b/web/pgadmin/browser/server_groups/servers/directories/tests/__init__.py new file mode 100644 index 000000000..ed8fa7e13 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/__init__.py @@ -0,0 +1,15 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +from pgadmin.utils.route import BaseTestGenerator + + +class DirectoriesCreateTestCase(BaseTestGenerator): + def runTest(self): + return diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/alter_directory_owner.msql b/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/alter_directory_owner.msql new file mode 100644 index 000000000..af01de047 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/alter_directory_owner.msql @@ -0,0 +1 @@ +ALTER DIRECTORY "Dir1_$%{}[]()&*^!@""'`\/#" OWNER TO enterprisedb; \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/alter_directory_owner.sql b/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/alter_directory_owner.sql new file mode 100644 index 000000000..9a3b360ec --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/alter_directory_owner.sql @@ -0,0 +1,8 @@ +-- Directory: Dir1_$%{}[]()&*^!@"'`\/# + +-- DROP DIRECTORY IF EXISTS Dir1_$%{}[]()&*^!@"'`\/#; + +CREATE DIRECTORY "Dir1_$%{}[]()&*^!@""'`\/#" AS '/home/test_dir'; + +ALTER DIRECTORY "Dir1_$%{}[]()&*^!@""'`\/#" + OWNER TO enterprisedb; \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/create_directory.msql b/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/create_directory.msql new file mode 100644 index 000000000..313e0b6f0 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/create_directory.msql @@ -0,0 +1,4 @@ +CREATE DIRECTORY "Dir1_$%{}[]()&*^!@""'`\/#" AS '/home/test_dir'; + +ALTER DIRECTORY "Dir1_$%{}[]()&*^!@""'`\/#" + OWNER TO enterprisedb; diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/create_directory.sql b/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/create_directory.sql new file mode 100644 index 000000000..9a3b360ec --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/create_directory.sql @@ -0,0 +1,8 @@ +-- Directory: Dir1_$%{}[]()&*^!@"'`\/# + +-- DROP DIRECTORY IF EXISTS Dir1_$%{}[]()&*^!@"'`\/#; + +CREATE DIRECTORY "Dir1_$%{}[]()&*^!@""'`\/#" AS '/home/test_dir'; + +ALTER DIRECTORY "Dir1_$%{}[]()&*^!@""'`\/#" + OWNER TO enterprisedb; \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/test.json b/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/test.json new file mode 100644 index 000000000..12950fd2a --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/ppas/default/test.json @@ -0,0 +1,36 @@ +{ + "scenarios": [ + { + "type": "create", + "name": "Create Directories", + "endpoint": "NODE-directory.obj", + "sql_endpoint": "NODE-directory.sql_id", + "msql_endpoint": "NODE-directory.msql", + "data": { + "name": "Dir1_$%{}[]()&*^!@\"'`\\/#", + "diruser": "enterprisedb", + "path": "/home/test_dir" + }, + "expected_sql_file": "create_directory.sql", + "expected_msql_file": "create_directory.msql" + }, + { + "type": "alter", + "name": "Alter Directory owner", + "endpoint": "NODE-directory.obj_id", + "sql_endpoint": "NODE-directory.sql_id", + "msql_endpoint": "NODE-directory.msql_id", + "data": { + "diruser": "enterprisedb" + }, + "expected_sql_file": "alter_directory_owner.sql", + "expected_msql_file": "alter_directory_owner.msql" + }, + { + "type": "delete", + "name": "Drop Directories", + "endpoint": "NODE-directory.obj_id", + "data": {} + } + ] +} diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_add.py b/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_add.py new file mode 100644 index 000000000..a70a011b1 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_add.py @@ -0,0 +1,63 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import json +import uuid + +from pgadmin.utils import server_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils +from . import utils as directories_utils + + +class DirectoriesAddTestCase(BaseTestGenerator): + """This class will test the add directories API""" + scenarios = [ + ('Add Directories', dict(url='/browser/directory/obj/')) + ] + + def setUp(self): + self.server_id = parent_node_dict["server"][-1]["server_id"] + server_con = server_utils.connect_server(self, self.server_id) + if server_con["info"] != "Server connected.": + raise Exception("Could not connect to server to add directory.") + if "type" in server_con["data"]: + if server_con["data"]["type"] == "pg": + message = "Directories are not supported by PG." + self.skipTest(message) + else: + if server_con["data"]["version"] < 130000: + message = "Directories are not supported by EPAS 12" \ + " and below." + self.skipTest(message) + + def runTest(self): + """This function will add directories under server node""" + self.directory = "test_directory_add%s" % \ + str(uuid.uuid4())[1:8] + data = { + "name": self.directory, + "path": "/home/test_dir" + } + response = self.tester.post(self.url + str(utils.SERVER_GROUP) + + "/" + str(self.server_id) + "/", + data=json.dumps(data), + content_type='html/json') + self.assertEqual(response.status_code, 200) + + def tearDown(self): + """This function delete the directory from the database.""" + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + directories_utils.delete_directories(connection, self.directory) diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_delete.py b/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_delete.py new file mode 100644 index 000000000..eace19c5d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_delete.py @@ -0,0 +1,65 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid + +from pgadmin.utils import server_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils +from . import utils as directories_utils + + +class DirectoriesDeleteTestCase(BaseTestGenerator): + """This class will delete the Directory""" + scenarios = [ + ('Delete Directory', dict(url='/browser/directory/obj/')) + ] + + def setUp(self): + self.server_id = parent_node_dict["server"][-1]["server_id"] + server_response = server_utils.connect_server(self, self.server_id) + if server_response["info"] != "Server connected.": + raise Exception("Could not connect to server to add directories.") + if "type" in server_response["data"]: + if server_response["data"]["type"] == "pg": + message = "Directories are not supported by PG." + self.skipTest(message) + else: + if server_response["data"]["version"] < 130000: + message = "Directories are not supported by EPAS " \ + "12 and below." + self.skipTest(message) + self.directory_name = "test_directory_delete%s" % \ + str(uuid.uuid4())[1:8] + self.directory_path = "/home/test_dir" + self.directory_id = directories_utils.create_directories( + self.server, self.directory_name, self.directory_path) + + def runTest(self): + """This function will delete Directory.""" + directory_response = directories_utils.verify_directory( + self.server, self.directory_name) + if not directory_response: + raise Exception("Could not find the Directory to fetch.") + response = self.tester.delete( + "{0}{1}/{2}/{3}".format(self.url, utils.SERVER_GROUP, + self.server_id, self.directory_id), + follow_redirects=True) + self.assertEqual(response.status_code, 200) + + def tearDown(self): + """This function delete the Directory from the database.""" + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + directories_utils.delete_directories(connection, self.directory_name) diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_delete_multiple.py b/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_delete_multiple.py new file mode 100644 index 000000000..137cdd4ec --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_delete_multiple.py @@ -0,0 +1,96 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +import json + +from pgadmin.utils import server_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils +from . import utils as directories_utils + + +class DirectoriesDeleteTestCase(BaseTestGenerator): + """This class will delete the directories""" + scenarios = [ + ('Delete multiple directories', + dict(url='/browser/directory/obj/')) + ] + + def setUp(self): + self.server_id = parent_node_dict["server"][-1]["server_id"] + server_response = server_utils.connect_server(self, self.server_id) + if server_response["info"] != "Server connected.": + raise Exception("Could not connect to server to add directory.") + if "type" in server_response["data"]: + if server_response["data"]["type"] == "pg": + message = "directories are not supported by PG." + self.skipTest(message) + else: + if server_response["data"]["version"] < 130000: + message = "directories are not supported by EPAS 12 " \ + "and below." + self.skipTest(message) + self.directory_names = ["test_directory_delete%s" % + str(uuid.uuid4())[1:8], + "test_directory_delete%s" % + str(uuid.uuid4())[1:8]] + self.directory_paths = ["/home/test_dir", "/home/test_dir1"] + self.directory_ids = [ + directories_utils.create_directories( + self.server, self.directory_names[0], self.directory_paths[0]), + directories_utils.create_directories( + self.server, self.directory_names[1], self.directory_paths[1])] + + def runTest(self): + """This function will delete directories.""" + directory_response = directories_utils.verify_directory( + self.server, self.directory_names[0]) + if not directory_response: + raise Exception("Could not find the directory to fetch.") + + directory_response = directories_utils.verify_directory( + self.server, self.directory_names[1]) + if not directory_response: + raise Exception("Could not find the directory to fetch.") + + data = {'ids': self.directory_ids} + response = self.tester.delete( + "{0}{1}/{2}/".format(self.url, + utils.SERVER_GROUP, + self.server_id), + follow_redirects=True, + data=json.dumps(data), + content_type='html/json' + ) + self.assertEqual(response.status_code, 200) + + def tearDown(self): + """This function delete the directory from the database.""" + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + directories_utils.delete_directories( + connection, + self.directory_names[0] + ) + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + directories_utils.delete_directories( + connection, + self.directory_names[1] + ) diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_get.py b/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_get.py new file mode 100644 index 000000000..9dba5c72b --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_get.py @@ -0,0 +1,65 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid + +from pgadmin.utils import server_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils +from . import utils as directorys_utils + + +class DirectoriesGetTestCase(BaseTestGenerator): + """This class will get the directories""" + scenarios = [ + ('Get directories', dict(url='/browser/directory/obj/')) + ] + + def setUp(self): + self.server_id = parent_node_dict["server"][-1]["server_id"] + server_response = server_utils.connect_server(self, self.server_id) + if server_response["info"] != "Server connected.": + raise Exception("Could not connect to server to add directories") + if "type" in server_response["data"]: + if server_response["data"]["type"] == "pg": + message = "directories are not supported by PG." + self.skipTest(message) + else: + if server_response["data"]["version"] < 13000: + message = "directories are not supported by EPAS 12" \ + " and below." + self.skipTest(message) + self.directory_name = "test_directory_get%s" % \ + str(uuid.uuid4())[1:8] + self.directory_path = "/home/test_dir" + self.directory_id = directorys_utils.create_directories( + self.server, self.directory_name, self.directory_path) + + def runTest(self): + """This function will get the directories.""" + directory_response = directorys_utils.verify_directory( + self.server, self.directory_name) + if not directory_response: + raise Exception("Could not find the directory to fetch.") + response = self.tester.get( + "{0}{1}/{2}/{3}".format(self.url, utils.SERVER_GROUP, + self.server_id, self.directory_id), + follow_redirects=True) + self.assertEqual(response.status_code, 200) + + def tearDown(self): + """This function delete the directory from the database.""" + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + directorys_utils.delete_directories(connection, self.directory_name) diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_put.py b/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_put.py new file mode 100644 index 000000000..ca5c6481d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/test_directories_put.py @@ -0,0 +1,81 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import json +import uuid + +from pgadmin.utils import server_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils +from . import utils as directories_utils +from . import utils as roles_utils +from regression.python_test_utils import test_utils + + +class DirectoriesPutTestCase(BaseTestGenerator): + """This class will update the directories""" + scenarios = [ + ('Put directories', dict(url='/browser/directory/obj/')) + ] + + def setUp(self): + self.server_id = parent_node_dict["server"][-1]["server_id"] + server_response = server_utils.connect_server(self, self.server_id) + if server_response["info"] != "Server connected.": + raise Exception("Could not connect to server to add directories.") + if "type" in server_response["data"]: + if server_response["data"]["type"] == "pg": + message = "directories are not supported by PG." + self.skipTest(message) + else: + if server_response["data"]["version"] < 130000: + message = "directories are not supported by EPAS 12" \ + " and below." + self.skipTest(message) + self.directory_name = "test_directory_put%s" % \ + str(uuid.uuid4())[1:8] + self.directory_path = "/home/test_dir" + self.directory_id = directories_utils.create_directories( + self.server, self.directory_name, self.directory_path) + self.role_name = "role_for_directory_%s" % \ + str(uuid.uuid4())[1:8] + self.role = roles_utils.create_superuser_role( + self.server, self.role_name) + + def runTest(self): + """This function will update the directories.""" + directory_response = directories_utils.verify_directory( + self.server, self.directory_name) + if not directory_response: + raise Exception("Could not find the directory to fetch.") + self.directory_user = "test_directory_put%s" % \ + str(uuid.uuid4())[1:8] + data = {"id": self.directory_id, + "diruser": self.role_name} + url = '{0}{1}/{2}/{3}'.format( + self.url, utils.SERVER_GROUP, self.server_id, + self.directory_id) + response = self.tester.put( + url, + data=json.dumps(data), + follow_redirects=True + ) + self.assertEqual(response.status_code, 200) + + def tearDown(self): + """This function delete the directory and role from the database.""" + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + directories_utils.delete_directories(connection, self.directory_name) + test_utils.drop_role(self.server, "postgres", self.role) diff --git a/web/pgadmin/browser/server_groups/servers/directories/tests/utils.py b/web/pgadmin/browser/server_groups/servers/directories/tests/utils.py new file mode 100644 index 000000000..681799ffb --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/directories/tests/utils.py @@ -0,0 +1,122 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + + +import sys +import traceback + +from regression.python_test_utils import test_utils as utils +from pgadmin.browser.server_groups.servers.roles.tests import \ + utils as roles_utils + + +def create_directories( + server, + directory_name, + directory_path, +): + """ + This function create the directories into databases. + """ + try: + connection = utils.get_db_connection(server['db'], + server['username'], + server['db_password'], + server['host'], + server['port'], + server['sslmode']) + old_isolation_level = connection.isolation_level + utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + sql = f"CREATE DIRECTORY {directory_name} AS '{directory_path}'" + pg_cursor.execute(sql) + utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + # Get oid of newly created directory. + pg_cursor.execute("SELECT oid FROM pg_catalog.edb_dir WHERE " + " dirname='%s'" % directory_name) + directory = pg_cursor.fetchone() + directory_id = directory[0] + connection.close() + return directory_id + except Exception: + traceback.print_exc(file=sys.stderr) + + +def verify_directory(server, directory_name): + """ + This function verifies the directory exist in database or not. + """ + try: + connection = utils.get_db_connection(server['db'], + server['username'], + server['db_password'], + server['host'], + server['port'], + server['sslmode']) + pg_cursor = connection.cursor() + pg_cursor.execute("SELECT * FROM pg_catalog.edb_dir WHERE " + " dirname='%s'" % directory_name) + directory = pg_cursor.fetchone() + connection.close() + return directory + except Exception: + traceback.print_exc(file=sys.stderr) + + +def delete_directories(connection, directory_name): + """ + This function deletes the directory. + """ + try: + pg_cursor = connection.cursor() + pg_cursor.execute("SELECT * FROM pg_catalog.edb_dir WHERE" + " dirname='%s'" % directory_name) + directory_name_count = len(pg_cursor.fetchall()) + if directory_name_count: + old_isolation_level = connection.isolation_level + utils.set_isolation_level(connection, 0) + pg_cursor.execute("DROP DIRECTORY IF EXISTS %s" % directory_name) + utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + connection.close() + except Exception: + traceback.print_exc(file=sys.stderr) + + +def create_superuser_role(server, role_name): + """ + This function create the role as superuser. + """ + try: + connection = utils.get_db_connection(server['db'], + server['username'], + server['db_password'], + server['host'], + server['port'], + server['sslmode']) + old_isolation_level = connection.isolation_level + utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + sql = ''' + CREATE USER "%s" WITH + SUPERUSER + ''' % (role_name) + pg_cursor.execute(sql) + utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + # Get oid of newly created directory + pg_cursor.execute("SELECT usename FROM pg_user WHERE " + " usename='%s'" % role_name) + user_role = pg_cursor.fetchone() + role_username = user_role[0] + connection.close() + return role_username + except Exception: + traceback.print_exc(file=sys.stderr) diff --git a/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_add.py b/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_add.py index 5e0e8626e..0f6069ca4 100644 --- a/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_add.py +++ b/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_add.py @@ -35,7 +35,7 @@ class ResourceGroupsAddTestCase(BaseTestGenerator): self.skipTest(message) else: if server_con["data"]["version"] < 90400: - message = "Resource groups are not supported by PPAS 9.3" \ + message = "Resource groups are not supported by EPAS 9.3" \ " and below." self.skipTest(message) diff --git a/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_delete.py b/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_delete.py index 620d9602f..85f9726b4 100644 --- a/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_delete.py +++ b/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_delete.py @@ -34,7 +34,7 @@ class ResourceGroupsDeleteTestCase(BaseTestGenerator): self.skipTest(message) else: if server_response["data"]["version"] < 90400: - message = "Resource groups are not supported by PPAS " \ + message = "Resource groups are not supported by EPAS " \ "9.3 and below." self.skipTest(message) self.resource_group = "test_resource_group_delete%s" % \ diff --git a/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_delete_multiple.py b/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_delete_multiple.py index e3c1c63e5..ff4f518b3 100644 --- a/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_delete_multiple.py +++ b/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_delete_multiple.py @@ -36,7 +36,7 @@ class ResourceGroupsDeleteTestCase(BaseTestGenerator): self.skipTest(message) else: if server_response["data"]["version"] < 90400: - message = "Resource groups are not supported by PPAS " \ + message = "Resource groups are not supported by EPAS " \ "9.3 and below." self.skipTest(message) self.resource_groups = ["test_resource_group_delete%s" % diff --git a/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_put.py b/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_put.py index 71837261f..c78bb8cad 100644 --- a/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_put.py +++ b/web/pgadmin/browser/server_groups/servers/resource_groups/tests/test_resource_groups_put.py @@ -35,7 +35,7 @@ class ResourceGroupsPutTestCase(BaseTestGenerator): self.skipTest(message) else: if server_response["data"]["version"] < 90400: - message = "Resource groups are not supported by PPAS 9.3" \ + message = "Resource groups are not supported by EPAS 9.3" \ " and below." self.skipTest(message) self.resource_group_name = "test_resource_group_put%s" % \ diff --git a/web/pgadmin/browser/server_groups/servers/resource_groups/tests/tests_resource_groups_get.py b/web/pgadmin/browser/server_groups/servers/resource_groups/tests/tests_resource_groups_get.py index 6e6e44c4c..31a383085 100644 --- a/web/pgadmin/browser/server_groups/servers/resource_groups/tests/tests_resource_groups_get.py +++ b/web/pgadmin/browser/server_groups/servers/resource_groups/tests/tests_resource_groups_get.py @@ -34,7 +34,7 @@ class ResourceGroupsGetTestCase(BaseTestGenerator): self.skipTest(message) else: if server_response["data"]["version"] < 90400: - message = "Resource groups are not supported by PPAS 9.3" \ + message = "Resource groups are not supported by EPAS 9.3" \ " and below." self.skipTest(message) self.resource_group = "test_resource_group_get%s" % \ diff --git a/web/pgadmin/browser/server_groups/servers/roles/tests/role_test_data.json b/web/pgadmin/browser/server_groups/servers/roles/tests/role_test_data.json index 94a9ca6f3..b981929e4 100644 --- a/web/pgadmin/browser/server_groups/servers/roles/tests/role_test_data.json +++ b/web/pgadmin/browser/server_groups/servers/roles/tests/role_test_data.json @@ -80,7 +80,7 @@ "new_role_name": "CURRENT_ROLE" }, "server_min_version": 140000, - "skip_msg": "CURRENT_ROLE are not supported by PPAS/PG 13.0 and below.", + "skip_msg": "CURRENT_ROLE are not supported by EPAS/PG 13.0 and below.", "mocking_required": false, "mock_data": {}, "expected_data": { @@ -196,7 +196,7 @@ "new_role_name": "CURRENT_ROLE" }, "server_min_version": 140000, - "skip_msg": "CURRENT_ROLE are not supported by PPAS/PG 13.0 and below.", + "skip_msg": "CURRENT_ROLE are not supported by EPAS/PG 13.0 and below.", "mocking_required": false, "mock_data": {}, "expected_data": { diff --git a/web/pgadmin/browser/server_groups/servers/utils.py b/web/pgadmin/browser/server_groups/servers/utils.py index 2402f52da..9b5e6b7b9 100644 --- a/web/pgadmin/browser/server_groups/servers/utils.py +++ b/web/pgadmin/browser/server_groups/servers/utils.py @@ -114,7 +114,9 @@ def parse_priv_to_db(str_privileges, allowed_acls=[]): 'T': 'TEMPORARY', 'a': 'INSERT', 'r': 'SELECT', + 'R': 'READ', 'w': 'UPDATE', + 'W': 'WRITE', 'd': 'DELETE', 'D': 'TRUNCATE', 'x': 'REFERENCES', diff --git a/web/pgadmin/browser/templates/browser/js/utils.js b/web/pgadmin/browser/templates/browser/js/utils.js index 4bbbdbc3f..24baa5264 100644 --- a/web/pgadmin/browser/templates/browser/js/utils.js +++ b/web/pgadmin/browser/templates/browser/js/utils.js @@ -81,7 +81,7 @@ define('pgadmin.browser.utils', 'coll-role', 'role', 'coll-resource_group', 'resource_group', 'coll-database', 'coll-pga_job', 'coll-pga_schedule', 'coll-pga_jobstep', 'pga_job', 'pga_schedule', 'pga_jobstep', - 'coll-replica_node', 'replica_node' + 'coll-replica_node', 'replica_node','coll-directory','directory' ]; pgBrowser.utils = { diff --git a/web/pgadmin/static/js/components/Privilege.jsx b/web/pgadmin/static/js/components/Privilege.jsx index 96bd3b51e..eed688fb3 100644 --- a/web/pgadmin/static/js/components/Privilege.jsx +++ b/web/pgadmin/static/js/components/Privilege.jsx @@ -36,6 +36,8 @@ export default function Privilege({value, onChange, controlProps}) { 'c': 'CONNECT', 'a': 'INSERT', 'r': 'SELECT', + 'R': 'READ', + 'W': 'WRITE', 'w': 'UPDATE', 'd': 'DELETE', 'D': 'TRUNCATE', diff --git a/web/regression/__init__.py b/web/regression/__init__.py index 5a54d9963..3a4ba4576 100644 --- a/web/regression/__init__.py +++ b/web/regression/__init__.py @@ -22,7 +22,8 @@ node_info_dict = { "did": [], # database "lrid": [], # role "tsid": [], # tablespace - "scid": [] # schema + "scid": [], # schema + "oid": [] # directory } global parent_node_dict @@ -31,5 +32,6 @@ parent_node_dict = { "database": [], "tablespace": [], "role": [], - "schema": [] + "schema": [], + "directory": [] } diff --git a/web/regression/javascript/schema_ui_files/directory.ui.spec.js b/web/regression/javascript/schema_ui_files/directory.ui.spec.js new file mode 100644 index 000000000..e26327eaf --- /dev/null +++ b/web/regression/javascript/schema_ui_files/directory.ui.spec.js @@ -0,0 +1,49 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + + +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import DirectorySchema from '../../../pgadmin/browser/server_groups/servers/directories/static/js/directory.ui'; + +import {genericBeforeEach, getCreateView, getEditView, getPropertiesView} from '../genericFunctions'; + +class MockSchema extends BaseUISchema { + get baseFields() { + return []; + } +} + +describe('DirectorySchema', ()=>{ + + const createSchemaObject = () => new DirectorySchema( + ()=>new MockSchema(), + { + role: ()=>[], + nodeInfo: {server: {user: {name:'ppass', id:0}}} + }, + ); + let getInitData = ()=>Promise.resolve({}); + + beforeEach(()=>{ + genericBeforeEach(); + }); + + it('create', async ()=>{ + await getCreateView(createSchemaObject()); + }); + + it('edit', async ()=>{ + await getEditView(createSchemaObject(), getInitData); + }); + + it('properties', async ()=>{ + await getPropertiesView(createSchemaObject(), getInitData); + }); +}); + diff --git a/web/webpack.config.js b/web/webpack.config.js index bd2d0a1d8..d6640ff12 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -179,6 +179,7 @@ module.exports = [{ 'pure|pgadmin.node.publication', 'pure|pgadmin.node.subscription', 'pure|pgadmin.node.tablespace', + 'pure|pgadmin.node.directory', 'pure|pgadmin.node.resource_group', 'pure|pgadmin.node.event_trigger', 'pure|pgadmin.node.extension', diff --git a/web/webpack.shim.js b/web/webpack.shim.js index bb73bf122..03042600c 100644 --- a/web/webpack.shim.js +++ b/web/webpack.shim.js @@ -128,6 +128,7 @@ let webpackShimConfig = { 'pgadmin.node.synonym': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/synonyms/static/js/synonym'), 'pgadmin.node.table': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table'), 'pgadmin.node.tablespace': path.join(__dirname, './pgadmin/browser/server_groups/servers/tablespaces/static/js/tablespace'), + 'pgadmin.node.directory': path.join(__dirname, './pgadmin/browser/server_groups/servers/directories/static/js/directory'), 'pgadmin.node.trigger': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/tables/triggers/static/js/trigger'), 'pgadmin.node.trigger_function': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/functions/static/js/trigger_function'), 'pgadmin.node.type': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/types/static/js/type'),