From 0d287df6ddc3021d692ae741fb8c8d78a059f213 Mon Sep 17 00:00:00 2001 From: Khushboo Vashi Date: Thu, 21 Dec 2023 12:07:26 +0530 Subject: [PATCH] Administer pgAdmin Users and Preferences Using the Command Line Interface (CLI). #2483 --- docs/en_US/import_export_servers.rst | 26 +- docs/en_US/preferences.rst | 37 ++ docs/en_US/user_management.rst | 139 +++++ pkg/docker/entrypoint.sh | 4 +- pkg/linux/setup-web.sh | 2 +- pkg/pip/setup_pip.py | 3 +- requirements.txt | 1 + web/pgAdmin4.py | 2 +- web/pgadmin/__init__.py | 1 + web/pgadmin/browser/server_groups/__init__.py | 2 +- web/pgadmin/preferences/__init__.py | 44 +- .../js/components/PreferencesComponent.jsx | 6 + .../setup/tests/test_export_import_servers.py | 4 +- web/pgadmin/static/js/SchemaView/FormView.jsx | 6 +- .../static/js/SchemaView/MappedControl.jsx | 2 +- .../js/components/KeyboardShortcuts.jsx | 9 +- web/pgadmin/tools/user_management/__init__.py | 9 +- web/pgadmin/utils/__init__.py | 30 +- web/pgadmin/utils/csrf.py | 1 + web/pgadmin/utils/preferences.py | 28 + .../components/KeyboardShortcuts.spec.js | 2 +- web/regression/runtests.py | 5 +- web/setup.py | 551 ++++++++++++++---- 23 files changed, 749 insertions(+), 165 deletions(-) diff --git a/docs/en_US/import_export_servers.rst b/docs/en_US/import_export_servers.rst index 96df462b1..c93b07ea8 100644 --- a/docs/en_US/import_export_servers.rst +++ b/docs/en_US/import_export_servers.rst @@ -61,7 +61,7 @@ Exporting Servers ***************** To export the servers defined in an installation, simply invoke ``setup.py`` with -the ``--dump-servers`` command line option, followed by the name (and if required, +the ``dump-servers`` command line option, followed by the name (and if required, path) to the desired output file. By default, servers owned by the desktop mode user will be dumped (pgadmin4@pgadmin.org by default - see the DESKTOP_USER setting in ``config.py``). This can be overridden with the ``--user`` command @@ -73,28 +73,28 @@ For example: .. code-block:: bash - /path/to/python /path/to/setup.py --dump-servers output_file.json + /path/to/python /path/to/setup.py dump-servers output_file.json - # or, to specify a non-default user name: + # or, to specify a non-default user name and auth source (the default is Internal): - /path/to/python /path/to/setup.py --dump-servers output_file.json --user user@example.com + /path/to/python /path/to/setup.py dump-servers output_file.json --user user@example.com --auth_source ldap # to specify a pgAdmin config DB file: - /path/to/python /path/to/setup.py --dump-servers output_file.json --sqlite-path /path/to/pgadmin4.db + /path/to/python /path/to/setup.py dump-servers output_file.json --sqlite-path /path/to/pgadmin4.db -To export only certain servers, use the ``--servers`` option and list one or +To export only certain servers, use the ``--server`` option and list one or more server IDs. For example: .. code-block:: bash - /path/to/python /path/to/setup.py --dump-servers output_file.json --server 1 2 5 + /path/to/python /path/to/setup.py dump-servers output_file.json --server 1 --server 2 --server 5 Importing Servers ***************** To import the servers defined in a JSON file, simply invoke ``setup.py`` with -the ``--load-servers`` command line option, followed by the name (and if required, +the ``load-servers`` command line option, followed by the name (and if required, path) of the JSON file containing the server definitions. Servers will be owned by the desktop mode user (pgadmin4@pgadmin.org by default - see the DESKTOP_USER setting in ``config.py``). This can be overridden with the ``--user`` command @@ -108,19 +108,19 @@ desktop mode. By default SQLITE_PATH setting in ``config.py`` is taken. For exam .. code-block:: bash - /path/to/python /path/to/setup.py --load-servers input_file.json + /path/to/python /path/to/setup.py load-servers input_file.json # or, to replace the list of servers with the newly imported one: - /path/to/python /path/to/setup.py --load-servers input_file.json --replace + /path/to/python /path/to/setup.py load-servers input_file.json --replace - # or, to specify a non-default user name to own the new servers: + # or, to specify a non-default user name and auth source (the default is Internal) to own the new servers: - /path/to/python /path/to/setup.py --load-servers input_file.json --user user@example.com + /path/to/python /path/to/setup.py load-servers input_file.json --user user@example.com # to specify a pgAdmin config DB file: - /path/to/python /path/to/setup.py --load-servers input_file.json --sqlite-path /path/to/pgadmin4.db + /path/to/python /path/to/setup.py load-servers input_file.json --sqlite-path /path/to/pgadmin4.db If any Servers are defined with a Server Group that is not already present in the configuration database, the required Group will be created. diff --git a/docs/en_US/preferences.rst b/docs/en_US/preferences.rst index 5ce771849..64019bd37 100644 --- a/docs/en_US/preferences.rst +++ b/docs/en_US/preferences.rst @@ -549,3 +549,40 @@ Use the fields on the *Options* panel to specify storage preferences. * When the *Show hidden files and folders?* switch is set to *True*, the file manager will display hidden files and folders. + + +Using 'setup.py' command line script +#################################### + +.. note:: To manage preferences using ``setup.py`` script, you must use + the Python interpreter that is normally used to run pgAdmin to ensure + that the required Python packages are available. In most packages, this + can be found in the Python Virtual Environment that can be found in the + installation directory. When using platform-native packages, the system + installation of Python may be the one used by pgAdmin. + + +Manage Preferences +****************** + +Get Preferences +*************** +To get all the preferences listed, invoke ``setup.py`` with ``get-prefs`` command line option. +You can also get this mapping by hovering the individual preference in the Preference UI dialog. + +.. code-block:: bash + + /path/to/python /path/to/setup.py get-prefs + +Save Preferences +**************** +To save the preferences, invoke ``setup.py`` with ``set-prefs`` command line option, followed by username, +preference_key=value and auth_source. Multiple preference can be given too by a space separated. +If auth_source is not given, Internal authentication will be consider by default. + +.. code-block:: bash + + /path/to/python /path/to/setup.py set-prefs user1@gmail.com sqleditor:editor:comma_first=true + + # to specify an auth_source + /path/to/python /path/to/setup.py set-prefs user1@gmail.com sqleditor:editor:comma_first=true --auth-source=ldap diff --git a/docs/en_US/user_management.rst b/docs/en_US/user_management.rst index d9681f453..2ddb59172 100644 --- a/docs/en_US/user_management.rst +++ b/docs/en_US/user_management.rst @@ -75,3 +75,142 @@ users, but otherwise have the same capabilities as those with the *User* role. * Click the *Help* button (?) to access online help. * Click the *Close* button to save work. You will be prompted to return to the dialog if your selections cannot be saved. + + +Using 'setup.py' command line script +#################################### + +.. note:: To manage users using ``setup.py`` script, you must use + the Python interpreter that is normally used to run pgAdmin to ensure + that the required Python packages are available. In most packages, this + can be found in the Python Virtual Environment that can be found in the + installation directory. When using platform-native packages, the system + installation of Python may be the one used by pgAdmin. + + When using PIP wheel package to install pgadmin, all the commands can be used + without Python interpreter. + + Some of the examples: + pgadmin4-cli add-user user1@gmail.com password --role 1 + pgadmin4-cli get-prefs + +Manage Users +************* + +Add User +********* + +To add user, invoke ``setup.py`` with ``add-user`` command line option, followed by +email and password. role and active will be optional fields. + +.. code-block:: bash + + /path/to/python /path/to/setup.py add-user user1@gmail.com password + + # to specify a role, admin and non-admin users: + + /path/to/python /path/to/setup.py add-user user1@gmail.com password --admin + /path/to/python /path/to/setup.py add-user user1@gmail.com password --nonadmin + + # to specify user's status + + /path/to/python /path/to/setup.py add-user user1@gmail.com password --active + /path/to/python /path/to/setup.py add-user user1@gmail.com password --inactive + +Add External User +***************** + +To add external authentication user, invoke ``setup.py`` with ``add-external-user`` command line option, +followed by email, password and authentication source. email, role and status will be optional fields. + +.. code-block:: bash + + /path/to/python /path/to/setup.py add-external-user user1@gmail.com ldap + + # to specify an email: + + /path/to/python /path/to/setup.py add-external-user ldapuser ldap --email user1@gmail.com + + # to specify a role, admin and non-admin user: + + /path/to/python /path/to/setup.py add-external-user ldapuser ldap --admin + /path/to/python /path/to/setup.py add-external-user ldapuser ldap --nonadmin + + # to specify user's status + + /path/to/python /path/to/setup.py add-external-user user1@gmail.com ldap --active + /path/to/python /path/to/setup.py add-external-user user1@gmail.com ldap --inactive + +Update User +*********** + +To update user, invoke ``setup.py`` with ``update-user`` command line option, followed by +email address. password, role and active are updatable fields. + +.. code-block:: bash + + /path/to/python /path/to/setup.py update-user user1@gmail.com --password new-password + + # to specify a role, admin and non-admin user: + + /path/to/python /path/to/setup.py update-user user1@gmail.com password --role --admin + /path/to/python /path/to/setup.py update-user user1@gmail.com password --role --nonadmin + + # to specify user's status + + /path/to/python /path/to/setup.py update-user user1@gmail.com password --active + /path/to/python /path/to/setup.py update-user user1@gmail.com password --inactive + +Update External User +******************** + +To update the external user, invoke ``setup.py`` with ``update-external-user`` command line option, +followed by username and auth source. email, password, role and active are updatable fields. + +.. code-block:: bash + + # to change email address: + + /path/to/python /path/to/setup.py update-external-user ldap ldapuser --email newemail@gmail.com + + # to specify a role, admin and non-admin user: + + /path/to/python /path/to/setup.py update-user user1@gmail.com password --role --admin + /path/to/python /path/to/setup.py update-user user1@gmail.com password --role --nonadmin + + # to change user's status + + /path/to/python /path/to/setup.py update-user ldap ldapuser --active + /path/to/python /path/to/setup.py update-user ldap ldapuser --inactive + +Delete User +*********** + +To delete the user, invoke ``setup.py`` with ``delete-user`` command line option, followed by +username and auth_source. For Internal users, email adress will be used instead of username. + +.. code-block:: bash + + /path/to/python /path/to/setup.py delete-user user1@gmail.com --auth-source internal + /path/to/python /path/to/setup.py delete-user ldapuser --auth-source ldap + + +Get User +******** + +To get the user details, invoke ``setup.py`` with ``get-users`` command line option, followed by +username/email address. + +.. code-block:: bash + + # to list all the users: + /path/to/python /path/to/setup.py get-users + + # to get the user's details: + /path/to/python /path/to/setup.py get-users --username user1@gmail.com + + +Output +****** + +Each command output can be seen in the json format too by adding --json command line option. \ No newline at end of file diff --git a/pkg/docker/entrypoint.sh b/pkg/docker/entrypoint.sh index cb22c555f..266477584 100755 --- a/pkg/docker/entrypoint.sh +++ b/pkg/docker/entrypoint.sh @@ -70,9 +70,9 @@ if [ ! -f /var/lib/pgadmin/pgadmin4.db ]; then # When running in Desktop mode, no user is created # so we have to import servers anonymously if [ "${PGADMIN_CONFIG_SERVER_MODE}" = "False" ]; then - /venv/bin/python3 /pgadmin4/setup.py --load-servers "${PGADMIN_SERVER_JSON_FILE}" + /venv/bin/python3 /pgadmin4/setup.py load-servers "${PGADMIN_SERVER_JSON_FILE}" else - /venv/bin/python3 /pgadmin4/setup.py --load-servers "${PGADMIN_SERVER_JSON_FILE}" --user "${PGADMIN_DEFAULT_EMAIL}" + /venv/bin/python3 /pgadmin4/setup.py load-servers "${PGADMIN_SERVER_JSON_FILE}" --user "${PGADMIN_DEFAULT_EMAIL}" fi fi fi diff --git a/pkg/linux/setup-web.sh b/pkg/linux/setup-web.sh index de4f18d23..c6a63ed65 100755 --- a/pkg/linux/setup-web.sh +++ b/pkg/linux/setup-web.sh @@ -72,7 +72,7 @@ fi # Run setup script first: echo "Creating configuration database..." -if ! /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py; +if ! /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py setup-db; then echo "Error setting up server mode. Please examine the output above." exit 1 diff --git a/pkg/pip/setup_pip.py b/pkg/pip/setup_pip.py index ec6c282df..3690279e6 100644 --- a/pkg/pip/setup_pip.py +++ b/pkg/pip/setup_pip.py @@ -100,7 +100,8 @@ setup( }, entry_points={ - 'console_scripts': ['pgadmin4=pgadmin4.pgAdmin4:main'], + 'console_scripts': ['pgadmin4=pgadmin4.pgAdmin4:main', + 'pgadmin4-cli=pgadmin4.setup:main'], }, ) diff --git a/requirements.txt b/requirements.txt index 352550cb2..ec48efb12 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,3 +60,4 @@ keyring==24.*; python_version > '3.7' keyring==23.*; python_version <= '3.7' Werkzeug==2.3.*; python_version > '3.7' Werkzeug==2.2.3; python_version <= '3.7' +typer[all]==0.9.* diff --git a/web/pgAdmin4.py b/web/pgAdmin4.py index 3d4b2d134..0777ea352 100644 --- a/web/pgAdmin4.py +++ b/web/pgAdmin4.py @@ -105,7 +105,7 @@ app = create_app() app.config['sessions'] = dict() if setup_db_required: - setup.setup_db(app) + setup.setup_db() # Authentication sources if len(config.AUTHENTICATION_SOURCES) > 0: diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py index afbb19c27..7c770200d 100644 --- a/web/pgadmin/__init__.py +++ b/web/pgadmin/__init__.py @@ -216,6 +216,7 @@ def create_app(app_name=None): app.config.from_object(config) app.config.update(dict(PROPAGATE_EXCEPTIONS=True)) + config.SETTINGS_SCHEMA_VERSION = CURRENT_SCHEMA_VERSION ########################################################################## # Setup logging and log the application startup ########################################################################## diff --git a/web/pgadmin/browser/server_groups/__init__.py b/web/pgadmin/browser/server_groups/__init__.py index 31fd43f12..9738a2058 100644 --- a/web/pgadmin/browser/server_groups/__init__.py +++ b/web/pgadmin/browser/server_groups/__init__.py @@ -185,7 +185,7 @@ class ServerGroupView(NodeView): # This matches the behavior of # web/pgadmin/utils/__init.py__#clear_database_servers # called by the setup script when importing and replacing servers: - # `python setup.py --load-servers input_file.json --replace` + # `python setup.py load-servers input_file.json --replace` sg = groups.first() shared_servers = Server.query.filter_by(servergroup_id=gid, diff --git a/web/pgadmin/preferences/__init__.py b/web/pgadmin/preferences/__init__.py index fd4229208..30fb18a88 100644 --- a/web/pgadmin/preferences/__init__.py +++ b/web/pgadmin/preferences/__init__.py @@ -48,8 +48,8 @@ class PreferencesModule(PgAdminModule): 'preferences.index', 'preferences.get_by_name', 'preferences.get_all', + 'preferences.get_all_cli', 'preferences.update_pref' - ] @@ -181,6 +181,28 @@ def preferences_s(): ) +@blueprint.route("/get_all_cli", methods=["GET"], endpoint='get_all_cli') +def get_all_cli(): + """Fetch all preferences for caching.""" + # Load Preferences + pref = Preferences.preferences() + res = {} + + for m in pref: + if len(m['categories']): + for c in m['categories']: + for p in c['preferences']: + p['module'] = m['name'] + res["{0}:{1}:{2}".format(m['label'], p['label'], c['label'] + )] = "{0}:{1}:{2}".format( + p['module'],c['name'],p['name']) + + return ajax_response( + response=res, + status=200 + ) + + def get_data(): """ Get preferences data. @@ -249,6 +271,26 @@ def save(): return response +def save_pref(data): + """ + Save a specific preference. + """ + + if data['name'] in ['vw_edt_tab_title_placeholder', + 'qt_tab_title_placeholder', + 'debugger_tab_title_placeholder'] \ + and data['value'].isspace(): + data['value'] = '' + + res, msg = Preferences.save_cli( + data['mid'], data['category_id'], data['id'], data['user_id'], + data['value']) + + if not res: + return False + return True + + @blueprint.route("/update", methods=["PUT"], endpoint="update_pref") @login_required def update(): diff --git a/web/pgadmin/preferences/static/js/components/PreferencesComponent.jsx b/web/pgadmin/preferences/static/js/components/PreferencesComponent.jsx index 35d63213c..f5a3274db 100644 --- a/web/pgadmin/preferences/static/js/components/PreferencesComponent.jsx +++ b/web/pgadmin/preferences/static/js/components/PreferencesComponent.jsx @@ -378,6 +378,7 @@ export default function PreferencesComponent({ ...props }) { if(field.visible && _.isNull(firstElement)) { firstElement = field; } + field.tooltip = item._parent._metadata.data.name + ':' + item._metadata.data.name + ':' + field.name; }); setLoadTree(crypto.getRandomValues(new Uint16Array(1))); initTreeTimeout = setTimeout(() => { @@ -598,6 +599,10 @@ export default function PreferencesComponent({ ...props }) { window.open(url_for('help.static', { 'filename': 'preferences.html' }), 'pgadmin_help'); }; + const onDialogHelpCli = () => { + window.open(url_for('preferences.get_all_cli'), 'pgadmin_help'); + }; + return ( @@ -623,6 +628,7 @@ export default function PreferencesComponent({ ...props }) { } title={gettext('Help for this dialog.')} /> + } title={gettext('Help for this dialog.')} /> { props.closeModal();}} startIcon={ { props.closeModal();}} />}> diff --git a/web/pgadmin/setup/tests/test_export_import_servers.py b/web/pgadmin/setup/tests/test_export_import_servers.py index 6c00841af..f3bbc33de 100644 --- a/web/pgadmin/setup/tests/test_export_import_servers.py +++ b/web/pgadmin/setup/tests/test_export_import_servers.py @@ -36,13 +36,13 @@ class ImportExportServersTestCase(BaseTestGenerator): # Load the servers os.system( - "python \"%s\" --load-servers \"%s\" 2> %s" % + "python \"%s\" load-servers \"%s\" 2> %s" % (setup, os.path.join(path, "servers.json"), os.devnull) ) # And dump them again tf = tempfile.NamedTemporaryFile(delete=False) - os.system("python \"%s\" --dump-servers \"%s\" 2> %s" % + os.system("python \"%s\" dump-servers \"%s\" 2> %s" % (setup, tf.name, os.devnull)) # Compare the JSON files, ignoring servers that exist in our diff --git a/web/pgadmin/static/js/SchemaView/FormView.jsx b/web/pgadmin/static/js/SchemaView/FormView.jsx index 2496cbaaa..cef2b31eb 100644 --- a/web/pgadmin/static/js/SchemaView/FormView.jsx +++ b/web/pgadmin/static/js/SchemaView/FormView.jsx @@ -8,7 +8,7 @@ ////////////////////////////////////////////////////////////// import React, { useContext, useEffect, useRef, useState } from 'react'; -import { Box, makeStyles, Tab, Tabs } from '@material-ui/core'; +import { Box, makeStyles, Tab, Tabs, Tooltip } from '@material-ui/core'; import _ from 'lodash'; import PropTypes from 'prop-types'; import clsx from 'clsx'; @@ -359,6 +359,10 @@ export default function FormView({ ]} />; + if(field.tooltip) { + currentControl = {currentControl}; + } + if(field.isFullTab && field.helpMessage) { currentControl = ( diff --git a/web/pgadmin/static/js/SchemaView/MappedControl.jsx b/web/pgadmin/static/js/SchemaView/MappedControl.jsx index 03b8bbe05..f73289f65 100644 --- a/web/pgadmin/static/js/SchemaView/MappedControl.jsx +++ b/web/pgadmin/static/js/SchemaView/MappedControl.jsx @@ -212,7 +212,7 @@ const ALLOWED_PROPS_FIELD_COMMON = [ 'label', 'options', 'optionsLoaded', 'controlProps', 'schema', 'inputRef', 'visible', 'autoFocus', 'helpMessage', 'className', 'optionsReloadBasis', 'orientation', 'isvalidate', 'fields', 'radioType', 'hideBrowseButton', 'btnName', 'hidden', - 'withContainer', 'controlGridBasis', 'hasCheckbox', 'treeData' + 'withContainer', 'controlGridBasis', 'hasCheckbox', 'treeData', 'title' ]; const ALLOWED_PROPS_FIELD_FORM = [ diff --git a/web/pgadmin/static/js/components/KeyboardShortcuts.jsx b/web/pgadmin/static/js/components/KeyboardShortcuts.jsx index d93d10192..d81cdba4a 100644 --- a/web/pgadmin/static/js/components/KeyboardShortcuts.jsx +++ b/web/pgadmin/static/js/components/KeyboardShortcuts.jsx @@ -27,7 +27,7 @@ const useStyles = makeStyles((theme) => ({ } })); -export default function KeyboardShortcuts({ value, onChange, fields }) { +export default function KeyboardShortcuts({ value, onChange, fields, title }) { const classes = useStyles(); const keyCid = _.uniqueId('c'); const keyhelpid = `h${keyCid}`; @@ -87,11 +87,11 @@ export default function KeyboardShortcuts({ value, onChange, fields }) { {element.label} - + } title={title} /> ; } else if (element.name == 'shift') { @@ -129,5 +129,6 @@ KeyboardShortcuts.propTypes = { value: PropTypes.object, onChange: PropTypes.func, controlProps: PropTypes.object, - fields: PropTypes.array + fields: PropTypes.array, + title: PropTypes.string }; diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py index 1bdbdde86..074eddf00 100644 --- a/web/pgadmin/tools/user_management/__init__.py +++ b/web/pgadmin/tools/user_management/__init__.py @@ -552,10 +552,12 @@ def update_user(uid, data): # Username and email can not be changed for internal users if usr.auth_source == INTERNAL: non_editable_params = ('username', 'email') + else: + non_editable_params = ('username',) - for f in non_editable_params: - if f in data: - return False, _("'{0}' is not allowed to modify.").format(f) + for f in non_editable_params: + if f in data: + return False, _("'{0}' is not allowed to modify.").format(f) try: new_data = validate_user(data) @@ -580,6 +582,7 @@ def delete_user(uid): This function is used to delete the users """ usr = User.query.get(uid) + if not usr: return False, _("Unable to update user '{0}'").format(uid) diff --git a/web/pgadmin/utils/__init__.py b/web/pgadmin/utils/__init__.py index fd0d3dd80..7f2558451 100644 --- a/web/pgadmin/utils/__init__.py +++ b/web/pgadmin/utils/__init__.py @@ -24,7 +24,7 @@ from threading import Lock from .paths import get_storage_directory from .preferences import Preferences from pgadmin.utils.constants import UTILITIES_ARRAY, USER_NOT_FOUND, \ - MY_STORAGE, ACCESS_DENIED_MESSAGE + MY_STORAGE, ACCESS_DENIED_MESSAGE, INTERNAL from pgadmin.utils.ajax import make_json_response from pgadmin.model import db, User, ServerGroup, Server from urllib.parse import unquote @@ -439,10 +439,11 @@ def add_value(attr_dict, key, value): def dump_database_servers(output_file, selected_servers, - dump_user=current_user, from_setup=False): + dump_user=current_user, from_setup=False, + auth_source=INTERNAL): """Dump the server groups and servers. """ - user = _does_user_exist(dump_user, from_setup) + user = _does_user_exist(dump_user, from_setup, auth_source) if user is None: return False, USER_NOT_FOUND % dump_user @@ -456,7 +457,10 @@ def dump_database_servers(output_file, selected_servers, servers = Server.query.filter_by(user_id=user_id).all() server_dict = {} for server in servers: - if selected_servers is None or str(server.id) in selected_servers: + if selected_servers is None or ( + isinstance(selected_servers, list) and len(selected_servers) == 0)\ + or str(server.id) in selected_servers\ + or server.id in selected_servers: # Get the group name group_name = ServerGroup.query.filter_by( user_id=user_id, id=server.servergroup_id).first().name @@ -592,10 +596,11 @@ def validate_json_data(data, is_admin): def load_database_servers(input_file, selected_servers, - load_user=current_user, from_setup=False): + load_user=current_user, from_setup=False, + auth_source=INTERNAL): """Load server groups and servers. """ - user = _does_user_exist(load_user, from_setup) + user = _does_user_exist(load_user, from_setup, auth_source) if user is None: return False, USER_NOT_FOUND % load_user @@ -745,10 +750,11 @@ def load_database_servers(input_file, selected_servers, return True, msg -def clear_database_servers(load_user=current_user, from_setup=False): +def clear_database_servers(load_user=current_user, from_setup=False, + auth_source=INTERNAL): """Clear groups and servers configurations. """ - user = _does_user_exist(load_user, from_setup) + user = _does_user_exist(load_user, from_setup, auth_source) if user is None: return False @@ -782,14 +788,16 @@ def clear_database_servers(load_user=current_user, from_setup=False): return False, error_msg -def _does_user_exist(user, from_setup): +def _does_user_exist(user, from_setup, auth_source=INTERNAL): """ This function will check user is exist or not. If exist then return """ if isinstance(user, User): - user = user.email + user = user.username + auth_source = user.auth_source - new_user = User.query.filter_by(email=user).first() + new_user = User.query.filter_by(username=user, + auth_source=auth_source).first() if new_user is None: print(USER_NOT_FOUND % user) diff --git a/web/pgadmin/utils/csrf.py b/web/pgadmin/utils/csrf.py index a1256cb50..65bb0b0c7 100644 --- a/web/pgadmin/utils/csrf.py +++ b/web/pgadmin/utils/csrf.py @@ -41,6 +41,7 @@ class _PGCSRFProtect(CSRFProtect): 'pgadmin.authenticate.login', 'pgadmin.tools.erd.panel', 'pgadmin.tools.psql.panel', + 'pgadmin.preferences.get_all_cli', ] for exempt in exempt_views: diff --git a/web/pgadmin/utils/preferences.py b/web/pgadmin/utils/preferences.py index 30bfbc003..d2775c360 100644 --- a/web/pgadmin/utils/preferences.py +++ b/web/pgadmin/utils/preferences.py @@ -594,6 +594,34 @@ class Preferences(): return None + @classmethod + def save_cli(cls, mid, cid, pid, user_id, value): + """ + save + Update the value for the preference in the configuration database. + + :param mid: Module ID + :param cid: Category ID + :param pid: Preference ID + :param value: Value for the options + """ + + pref = UserPrefTable.query.filter_by( + pid=pid + ).filter_by(uid=user_id).first() + + value = "{}".format(value) + if pref is None: + pref = UserPrefTable( + uid=user_id, pid=pid, value=value + ) + db.session.add(pref) + else: + pref.value = value + db.session.commit() + + return True, None + @classmethod def save(cls, mid, cid, pid, value): """ diff --git a/web/regression/javascript/components/KeyboardShortcuts.spec.js b/web/regression/javascript/components/KeyboardShortcuts.spec.js index 1f0054765..13832ccee 100644 --- a/web/regression/javascript/components/KeyboardShortcuts.spec.js +++ b/web/regression/javascript/components/KeyboardShortcuts.spec.js @@ -55,7 +55,7 @@ describe('KeyboardShortcuts', () => { fields={fields} controlProps={{ extraprop: 'test', - keyDown: onChange + 'keydown': onChange }} onChange={onChange} />); diff --git a/web/regression/runtests.py b/web/regression/runtests.py index 7c3b94b07..f32837c13 100644 --- a/web/regression/runtests.py +++ b/web/regression/runtests.py @@ -23,6 +23,7 @@ import threading import time import unittest import asyncio + from selenium.webdriver.firefox.options import Options as FirefoxOptions if sys.platform == "win32": @@ -89,9 +90,6 @@ if pgadmin_credentials and \ os.environ['PGADMIN_SETUP_PASSWORD'] = str(pgadmin_credentials[ 'login_password']) -# Execute the setup file -exec(open("setup.py").read()) - # Get the config database schema version. We store this in pgadmin.model # as it turns out that putting it in the config files isn't a great idea from pgadmin.model import SCHEMA_VERSION @@ -110,7 +108,6 @@ config.CONSOLE_LOG_LEVEL = WARNING # Create the app from pgAdmin4 import app -# app = create_app() app.app_context().push() app.PGADMIN_INT_KEY = '' diff --git a/web/setup.py b/web/setup.py index c519850d9..66b3a5a98 100644 --- a/web/setup.py +++ b/web/setup.py @@ -10,9 +10,16 @@ """Perform the initial setup of the application, by creating the auth and settings database.""" -import argparse import os import sys +import typer +from rich.console import Console +from rich.table import Table +from rich import box, print +import json as jsonlib + +console = Console() +app = typer.Typer() # We need to include the root directory in sys.path to ensure that we can # find everything we need when running in the standalone runtime. @@ -29,69 +36,446 @@ if 'SERVER_MODE' in globals(): else: builtins.SERVER_MODE = None -from pgadmin.model import db, Version, SCHEMA_VERSION as CURRENT_SCHEMA_VERSION +from pgadmin.model import db, Version, User,\ + SCHEMA_VERSION as CURRENT_SCHEMA_VERSION from pgadmin import create_app from pgadmin.utils import clear_database_servers, dump_database_servers,\ load_database_servers from pgadmin.setup import db_upgrade, create_app_data_directory +from typing import Optional, List +from typing_extensions import Annotated +from pgadmin.utils.constants import MIMETYPE_APP_JS, INTERNAL, LDAP, OAUTH2,\ + KERBEROS, WEBSERVER +from pgadmin.tools.user_management import create_user, delete_user, update_user +from enum import Enum + +app = typer.Typer(pretty_exceptions_show_locals=False) -def dump_servers(args): - """Dump the server groups and servers. +class ManageServers: - Args: - args (ArgParser): The parsed command line options - """ + @app.command() + def dump_servers(output_file: str, user: Optional[str] = None, + auth_source: Optional[str] = INTERNAL, + sqlite_path: Optional[str] = None, + server: List[int] = None): + """Dump the server groups and servers. """ - # What user? - if args.user is not None: - dump_user = args.user - else: - dump_user = config.DESKTOP_USER + # What user? + dump_user = user if user is not None else config.DESKTOP_USER - # And the sqlite path - if args.sqlite_path is not None: - config.SQLITE_PATH = args.sqlite_path + # And the sqlite path + if sqlite_path is not None: + config.SQLITE_PATH = sqlite_path - print('----------') - print('Dumping servers with:') - print('User:', dump_user) - print('SQLite pgAdmin config:', config.SQLITE_PATH) - print('----------') + print('----------') + print('Dumping servers with:') + print('User:', dump_user) + print('SQLite pgAdmin config:', config.SQLITE_PATH) + print('----------') - app = create_app(config.APP_NAME + '-cli') - with app.test_request_context(): - dump_database_servers(args.dump_servers, args.servers, dump_user, True) + app = create_app(config.APP_NAME + '-cli') + with app.test_request_context(): + dump_database_servers(output_file, server, dump_user, True, + auth_source) + + @app.command() + def load_servers(input_file: str, user: Optional[str] = None, + auth_source: Optional[str] = INTERNAL, + sqlite_path: Optional[str] = None, + replace: Optional[bool] = False + ): + + """Load server groups and servers.""" + + # What user? + load_user = user if user is not None else config.DESKTOP_USER + + # And the sqlite path + if sqlite_path is not None: + config.SQLITE_PATH = sqlite_path + + print('----------') + print('Loading servers with:') + print('User:', load_user) + print('SQLite pgAdmin config:', config.SQLITE_PATH) + print('----------') + + app = create_app(config.APP_NAME + '-cli') + with app.test_request_context(): + if replace: + clear_database_servers(load_user, True, auth_source) + load_database_servers(input_file, None, load_user, True, + auth_source) -def load_servers(args): - """Load server groups and servers. - - Args: - args (ArgParser): The parsed command line options - """ - - # What user? - load_user = args.user if args.user is not None else config.DESKTOP_USER - - # And the sqlite path - if args.sqlite_path is not None: - config.SQLITE_PATH = args.sqlite_path - - print('----------') - print('Loading servers with:') - print('User:', load_user) - print('SQLite pgAdmin config:', config.SQLITE_PATH) - print('----------') - - app = create_app(config.APP_NAME + '-cli') - with app.test_request_context(): - load_database_servers(args.load_servers, None, load_user, True) +class AuthExtTypes(str, Enum): + oauth2 = OAUTH2 + ldap = LDAP + kerberos = KERBEROS + webserver = WEBSERVER -def setup_db(app): +# Enum class can not be extended +class AuthType(str, Enum): + oauth2 = OAUTH2 + ldap = LDAP + kerberos = KERBEROS + webserver = WEBSERVER + internal = INTERNAL + + +class ManageUsers: + + @app.command() + def add_user(email: str, password: str, + role: Annotated[Optional[bool], typer.Option( + "--admin/--nonadmin")] = False, + active: Annotated[Optional[bool], + typer.Option("--active/--inactive")] = True, + console: Optional[bool] = True, + json: Optional[bool] = False + ): + """Add Internal user. """ + + data = { + 'email': email, + 'role': 1 if role else 2, + 'active': active, + 'auth_source': INTERNAL, + 'newPassword': password, + 'confirmPassword': password, + } + ManageUsers.create_user(data, console, json) + + @app.command() + def add_external_user(username: str, + auth_source: AuthExtTypes = AuthExtTypes.oauth2, + email: Optional[str] = None, + role: Annotated[Optional[bool], + typer.Option( + "--admin/--nonadmin")] = False, + active: Annotated[Optional[bool], + typer.Option( + "--active/--inactive")] = True, + console: Optional[bool] = True, + json: Optional[bool] = False + ): + """Add external user, other than Internal like + Ldap, Ouath2, Kerberos, Webserver. """ + + data = { + 'username': username, + 'email': email, + 'role': 1 if role else 2, + 'active': active, + 'auth_source': auth_source + } + ManageUsers.create_user(data, console, json) + + @app.command() + def delete_user(username: str, auth_source: AuthType = AuthType.internal): + """Delete the user. """ + delete = typer.confirm("Are you sure you want to delete it?") + + if delete: + app = create_app(config.APP_NAME + '-cli') + with app.test_request_context(): + uid = ManageUsers.get_user(username=username, + auth_source=auth_source) + if not uid: + print("User not found") + else: + status, msg = delete_user(uid) + if status: + print('User deleted successfully.') + else: + print('Something went wrong. ' + str(msg)) + + @app.command() + def update_user(email: str, + password: Optional[str] = None, + role: Annotated[Optional[bool], + typer.Option("--admin/--nonadmin" + )] = None, + active: Annotated[Optional[bool], + typer.Option("--active/--inactive" + )] = None, + console: Optional[bool] = True, + json: Optional[bool] = False + ): + """Update internal user.""" + + data = dict() + if password: + if len(password) < 6: + print("Password must be at least 6 characters long.") + exit() + data['password'] = password + + if role is not None: + data['role'] = 1 if role else 2 + if active is not None: + data['active'] = active + + app = create_app(config.APP_NAME + '-cli') + with app.test_request_context(): + uid = ManageUsers.get_user(username=email, + auth_source=INTERNAL) + if not uid: + print("User not found") + else: + status, msg = update_user(uid, data) + if status: + _user = ManageUsers.get_users(username=email, + auth_source=INTERNAL, + console=False) + ManageUsers.display_user(_user[0], console, json) + else: + print('Something went wrong. ' + str(msg)) + + @app.command() + def get_users(username:Optional[str] = None, + auth_source: AuthType = None, + console:Optional[bool] = True, + json:Optional[bool] = False + ): + """Get user(s) details.""" + app = create_app(config.APP_NAME + '-cli') + usr = None + with app.test_request_context(): + if username and auth_source: + users = User.query.filter_by(username=username, + auth_source=auth_source) + elif not username and auth_source: + users = User.query.filter_by(auth_source=auth_source) + elif username and not auth_source: + users = User.query.filter_by(username=username) + else: + users = User.query.all() + users_data = [] + for u in users: + _data = {'id': u.id, + 'username': u.username, + 'email': u.email, + 'active': u.active, + 'role': u.roles[0].id, + 'auth_source': u.auth_source, + 'locked': u.locked + } + users_data.append(_data) + if console: + ManageUsers.display_user(_data, False, json) + if not console: + return users_data + + @app.command() + def update_external_user(username: str, + auth_source: AuthExtTypes = AuthExtTypes.oauth2, + email: Optional[str] = None, + role: Annotated[Optional[bool], + typer.Option("--admin/--nonadmin" + )] = None, + active: Annotated[ + Optional[bool], + typer.Option("--active/--inactive")] = None, + console: Optional[bool] = True, + json: Optional[bool] = False + ): + """Update external users other than Internal like + Ldap, Ouath2, Kerberos, Webserver.""" + + data = dict() + if email: + data['email'] = email + if role is not None: + data['role'] = 1 if role else 2 + if active is not None: + data['active'] = active + + app = create_app(config.APP_NAME + '-cli') + with app.test_request_context(): + uid = ManageUsers.get_user(username=username, + auth_source=auth_source) + if not uid: + print("User not found") + else: + status, msg = update_user(uid, data) + if status: + _user = ManageUsers.get_users(username=username, + auth_source=auth_source, + console=False) + ManageUsers.display_user(_user[0], console, json) + else: + print('Something went wrong. ' + str(msg)) + + def create_user(data, console, json): + app = create_app(config.APP_NAME + '-cli') + with app.test_request_context(): + username = data['username'] if 'username' in data else\ + data['email'] + uid = ManageUsers.get_user(username=username, + auth_source=data['auth_source']) + if uid: + print("User already exists.") + exit() + + if 'newPassword' in data and len(data['newPassword']) < 6: + print("Password must be at least 6 characters long.") + exit() + + status, msg = create_user(data) + if status: + ManageUsers.display_user(data, console, json) + else: + print('Something went wrong. ' + str(msg)) + + def get_user(username=None, auth_source=INTERNAL): + app = create_app(config.APP_NAME + '-cli') + usr = None + with app.test_request_context(): + usr = User.query.filter_by(username=username, + auth_source=auth_source).first() + + if not usr: + return None + return usr.id + + def display_user(data, _console, _json): + if _json: + json_formatted_str = jsonlib.dumps(data, indent=0) + console.print(json_formatted_str) + else: + table = Table(title="User Details", box=box.ASCII) + table.add_column("Field", style="green") + table.add_column("Value", style="green") + + if 'username' in data: + table.add_row("Username", data['username']) + if 'email' in data: + table.add_row("Email", data['email']) + table.add_row("auth_source", data['auth_source']) + table.add_row("role", + "Admin" if data['role'] and data['role'] != 2 else + "Non-admin") + table.add_row("active", + 'True' if data['active'] else 'False') + console.print(table) + + +class ManagePreferences: + + def get_user(username=None, auth_source=INTERNAL): + app = create_app(config.APP_NAME + '-cli') + usr = None + with app.test_request_context(): + usr = User.query.filter_by(username=username, + auth_source=auth_source).first() + if not usr: + return None + return usr.id + + @app.command() + def get_prefs(id: Optional[bool] = None, json: Optional[bool] = False): + """Get Preferences List.""" + app = create_app(config.APP_NAME + '-cli') + table = Table(title="Pref Details", box=box.ASCII) + table.add_column("Preference", style="green") + with app.app_context(): + from pgadmin.preferences import save_pref + from pgadmin.utils.preferences import Preferences + from pgadmin.model import db, Preferences as PrefTable, \ + ModulePreference as ModulePrefTable, \ + UserPreference as UserPrefTable, \ + PreferenceCategory as PrefCategoryTbl + + module_prefs = ModulePrefTable.query.all() + cat_prefs = PrefCategoryTbl.query.all() + prefs = PrefTable.query.all() + if id: + all_preferences = {} + else: + all_preferences = [] + for i in module_prefs: + for j in cat_prefs: + if i.id == j.mid: + for k in prefs: + if k.cid == j.id: + if id: + all_preferences["{0}:{1}:{2}".format( + i.name, j.name, k.name) + ] = "{0}:{1}:{2}".format(i.id, j.id, k.id) + else: + table.add_row("{0}:{1}:{2}".format( + i.name, j.name, k.name)) + all_preferences.append( + "{0}:{1}:{2}".format( + i.name, j.name, k.name) + ) + if id: + return all_preferences + else: + if json: + json_formatted_str = jsonlib.dumps( + {"Preferences": all_preferences}, + indent=0) + console.print(json_formatted_str) + else: + console.print(table) + + @app.command() + def set_prefs(username, pref_options: List[str], + auth_source: AuthType = AuthType.internal, + json: Optional[bool] = False): + """Set User preferences.""" + user_id = ManagePreferences.get_user(username, auth_source) + app = create_app(config.APP_NAME + '-cli') + table = Table(title="Pref Details", box=box.ASCII) + table.add_column("Preference", style="green") + if not user_id: + print("User not found.") + return + + prefs = ManagePreferences.get_prefs(True) + app = create_app(config.APP_NAME + '-cli') + with app.app_context(): + from pgadmin.preferences import save_pref + for opt in pref_options: + val = opt.split("=") + final_opt = val[0].split(":") + val = val[1] + f = ":".join(final_opt) + if f in prefs: + ids = prefs[f].split(":") + save_pref({ + 'mid': ids[0], + 'category_id': ids[1], + 'id': ids[2], + 'name': final_opt[2], + 'user_id': user_id, + 'value': val}) + _row = { + 'mid': ids[0], + 'category_id': ids[1], + 'id': ids[2], + 'name': final_opt[2], + 'user_id': user_id, + 'value': val} + if json: + json_formatted_str = jsonlib.dumps(_row, indent=0) + console.print(json_formatted_str) + else: + table.add_row(jsonlib.dumps(_row)) + + if not json: + console.print(table) + + +@app.command() +def setup_db(): """Setup the configuration database.""" + app = create_app() create_app_data_directory(config) print("pgAdmin 4 - Application Initialisation") @@ -148,74 +532,5 @@ def setup_db(app): run_migration_for_sqlite() -def clear_servers(): - """Clear groups and servers configurations. - - Args: - args (ArgParser): The parsed command line options - """ - - # What user? - load_user = args.user if args.user is not None else config.DESKTOP_USER - - # And the sqlite path - if args.sqlite_path is not None: - config.SQLITE_PATH = args.sqlite_path - - app = create_app(config.APP_NAME + '-cli') - with app.app_context(): - clear_database_servers(load_user, True) - - -if __name__ == '__main__': - # Configuration settings - parser = argparse.ArgumentParser(description='Setup the pgAdmin config DB') - - exp_group = parser.add_argument_group('Dump server config') - exp_group.add_argument('--dump-servers', metavar="OUTPUT_FILE", - help='Dump the servers in the DB', required=False) - exp_group.add_argument('--servers', metavar="SERVERS", nargs='*', - help='One or more servers to dump', required=False) - - imp_group = parser.add_argument_group('Load server config') - imp_group.add_argument('--load-servers', metavar="INPUT_FILE", - help='Load servers into the DB', required=False) - imp_group.add_argument('--replace', dest='replace', action='store_true', - help='replace server configurations', - required=False) - - imp_group.set_defaults(replace=False) - # Common args - parser.add_argument('--sqlite-path', metavar="PATH", - help='Dump/load with the specified pgAdmin config DB' - ' file. This is particularly helpful when there' - ' are multiple pgAdmin configurations. It is also' - ' recommended to use this option when running' - ' pgAdmin in desktop mode.', required=False) - parser.add_argument('--user', metavar="USER_NAME", - help='Dump/load servers for the specified username', - required=False) - - args, extra = parser.parse_known_args() - - config.SETTINGS_SCHEMA_VERSION = CURRENT_SCHEMA_VERSION - if "PGADMIN_TESTING_MODE" in os.environ and \ - os.environ["PGADMIN_TESTING_MODE"] == "1": - config.SQLITE_PATH = config.TEST_SQLITE_PATH - - # What to do? - if args.dump_servers is not None: - try: - dump_servers(args) - except Exception as e: - print(str(e)) - elif args.load_servers is not None: - try: - if args.replace: - clear_servers() - load_servers(args) - except Exception as e: - print(str(e)) - else: - app = create_app() - setup_db(app) +if __name__ == "__main__": + app()