From 4569ccaa0c32ee3a88594105fc912c2d722c1180 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Thu, 2 Jul 2026 11:45:29 -0400 Subject: [PATCH 01/19] WIP: pg_timetable patterned after pgAgent --- .../servers/pg_timetable/__init__.py | 55 ++++++ .../servers/pg_timetable/chains/__init__.py | 172 ++++++++++++++++++ .../chains/sql/default/check_extension.sql | 4 + .../chains/sql/default/create.sql | 46 +++++ .../chains/sql/default/delete.sql | 13 ++ .../chains/sql/default/properties.sql | 30 +++ .../chains/sql/default/update.sql | 46 +++++ .../pg_timetable/static/css/pg_timetable.css | 24 +++ .../pg_timetable/static/js/pg_timestable.js | 59 ++++++ .../static/js/pg_timetable_schema.ui.js | 126 +++++++++++++ 10 files changed, 575 insertions(+) create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/delete.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py new file mode 100644 index 00000000000..8014c95a195 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -0,0 +1,55 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Implements the pg_timetable Jobs Node""" + +from flask_babelex import gettext +from pgadmin.browser.utils import PgAdminModule +from pgadmin.utils.ajax import make_json_response, internal_server_error + + +class PGTimetableModule(PgAdminModule): + def __init__(self, *args, **kwargs): + super(PGTimetableModule, self).__init__(*args, **kwargs) + self.submodules = [] + + def get_own_all_modules(self): + """Dynamically loads and initializes submodules like chains.""" + if not self.submodules: + from .chains import blueprint as chains_blueprint + self.submodules.append(chains_blueprint) + return self.submodules + + @property + def node_type(self): + return 'pg_timetable' + + @property + def label(self): + return gettext('pg_timetable') + + def get_own_stylesheets(self): + """Registers the custom CSS node icons.""" + return [ + 'pg_timetable/css/pg_timetable.css' + ] + + def get_own_javascripts(self): + """ + Registers the front-end JS files. This tells pgAdmin's script loader + to pull in your node definition file automatically on application load. + """ + return [{ + 'name': 'pg_timetable', + 'path': 'pg_timetable/js/pg_timetable', + 'when': None # Loads unconditionally during application boot + }] + +# Initialize the main extension blueprint +blueprint = PGTimetableModule('pg_timetable', __name__, static_folder='static') diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py new file mode 100644 index 00000000000..8acdceec91f --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py @@ -0,0 +1,172 @@ + +from pgadmin.browser.plugins import BrowserPluginModule +from pgadmin.browser.utils import NodeView +from pgadmin.utils.ajax import make_json_response, internal_server_error +from pgadmin.utils.driver import get_driver +from pgadmin.utils.constants import MANAGED_PROVIDER +from flask import request + +# 1. The Browser Module (registers the node infrastructure in pgAdmin) +class PGTimetableChainModule(BrowserPluginModule): + def __init__(self, *args, **kwargs): + super(PGTimetableChainModule, self).__init__(*args, **kwargs) + self.node_type = 'pg_timetable_chain' + self.parent_type = 'server' + + def get_nodes(self, gid, sid): + """Generates visual child tree items under the active Server connection.""" + pass + + +# Initialize the module instance for pgAdmin's plugin loader +blueprint = PGTimetableChainModule('pg_timetable_chain', __name__, static_folder='static') + + +# 2. The Node View (maps REST API endpoints to this specific node type) +class PGTimetableChainView(NodeView): + node_type = 'pg_timetable_chain' + parent_ids = [{'type': 'server', 'id': 'sid'}] + ids = [{'type': 'pg_timetable_chain', 'id': 'chain_id'}] + + def _get_connection(self, sid): + """Helper to get a connection pointing to the maintenance DB.""" + driver = get_driver(MANAGED_PROVIDER) + manager = driver.connection_manager(sid) + conn = manager.connection(manager.db) + return driver, conn + + def list(self, gid, sid): + """Triggered automatically when clicking the pg_timetable folder node.""" + try: + driver, conn = self._get_connection(sid) + + # Check if pg_timetable schema is present + sql_path = 'pg_timetable/chains/sql/default/check_extension.sql' + status, res = conn.execute_scalar(self.render_template_file(sql_path)) + + if not res: + return make_json_response(data=[]) # Hide gracefully if not installed + + # Fetch active cron chains + sql = "SELECT chain_id as id, name FROM timetable.chain;" + status, result = conn.execute_dict(sql) + + nodes = [] + for row in result['rows']: + nodes.append(self.blueprint.generate_browser_node( + id=row['id'], + label=row['name'], + icon='icon-pg_timetable_chain' + )) + return make_json_response(data=nodes) + except Exception as e: + return internal_server_error(errormsg=str(e)) + + def create(self, gid, sid): + """Invoked when a POST request hits the node endpoint with form data.""" + try: + # Read JSON payload sent by the React frontend + data = request.get_json() + driver, conn = self._get_connection(sid) + + # Render and execute the template file + sql = self.render_template_file( + 'pg_timetable/chains/sql/default/create.sql', + data=data + ) + status, result = conn.execute_dict(sql) + + # Fetch the ID of the freshly generated object to return to the UI tree + id_sql = f"SELECT chain_id FROM timetable.chain WHERE name = {driver.qtLiteral(data['name'])} ORDER BY chain_id DESC LIMIT 1;" + status, id_res = conn.execute_dict(id_sql) + new_id = id_res['rows'][0]['chain_id'] + + return make_json_response( + data=self.blueprint.generate_browser_node( + id=new_id, + label=data['name'], + icon='icon-pg_timetable_chain' + ) + ) + except Exception as e: + # Make sure to rollback if things fail mid-transaction + return internal_server_error(errormsg=str(e)) + + def update(self, gid, sid, chain_id): + """Invoked automatically when a PUT request hits the matching node instance.""" + try: + # Parse form edits sent down from the client model layer + data = request.get_json() + driver, conn = self._get_connection(sid) + + # Compile and push structural edits through the database driver connection + sql = self.render_template_file( + 'pg_timetable/chains/sql/default/update.sql', + data=data, + chain_id=chain_id + ) + status, result = conn.execute_dict(sql) + + # Fetch the updated name to verify if the sidebar tree label needs a rename event + current_name = data.get('name') + if not current_name: + name_sql = f"SELECT name FROM timetable.chain WHERE chain_id = {int(chain_id)};" + status, name_res = conn.execute_dict(name_sql) + current_name = name_res['rows'][0]['name'] + + return make_json_response( + data=self.blueprint.generate_browser_node( + id=chain_id, + label=current_name, + icon='icon-pg_timetable_chain' + ) + ) + except Exception as e: + # Prevent data contamination if validation fails midway through operations + return internal_server_error(errormsg=str(e)) + + def properties(self, gid, sid, chain_id): + """Invoked when pgAdmin requests data to fill the properties dashboard panel.""" + try: + driver, conn = self._get_connection(sid) + + # Render script using the specific clicked chain_id parameter + sql = self.render_template_file( + 'pg_timetable/chains/sql/default/properties.sql', + chain_id=chain_id + ) + status, result = conn.execute_dict(sql) + + if not result['rows']: + return internal_server_error(errormsg="Requested pg_timetable chain not found.") + + # Grab the row dictionary mapping directly to your React schema structure + data = result['rows'][0] + + return make_json_response(data=data) + except Exception as e: + return internal_server_error(errormsg=str(e)) + + def delete(self, gid, sid, chain_id): + """Invoked automatically when a user hits Delete/Drop on a tree node selection.""" + try: + driver, conn = self._get_connection(sid) + + # Compile parameters and push structural deletions to the cluster + sql = self.render_template_file( + 'pg_timetable/chains/sql/default/delete.sql', + chain_id=chain_id + ) + status, result = conn.execute_dict(sql) + + # Return a success flag response to notify the frontend to remove the node + return make_json_response( + success=True, + info="pg_timetable Chain successfully dropped." + ) + except Exception as e: + # Safe rollback operation triggers implicitly on driver connection breakdown + return internal_server_error(errormsg=str(e)) + +# 3. Connect the View routing back to the Module Blueprint +PGTimetableChainView.register_node_view(blueprint) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql new file mode 100644 index 00000000000..5ced2b0609e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql @@ -0,0 +1,4 @@ +{# Check if pg_timetable schema exists in the current connected database #} +SELECT EXISTS ( + SELECT 1 FROM pg_namespace WHERE nspname = 'timetable' +) AS has_timetable; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql new file mode 100644 index 00000000000..ab4c07f3846 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql @@ -0,0 +1,46 @@ +{# Create a transaction to ensure both the chain and tasks are added safely #} +BEGIN; + +{# 1. Register the base chain/job with the timetable system #} +SELECT timetable.add_job( + job_name => {{ data.name|qtLiteral }}, + job_schedule => {{ data.run_at|qtLiteral }}, + job_command => NULL, {# We leave this NULL because we add separate step tasks below #} + job_kind => 'SQL'::timetable.task_kind, + job_client_name => NULL, + job_max_instances => {{ data.max_instances|default(1, true)|int }}, + job_live => {{ data.live|lower }} +); + +{# + 2. Target the newly created job id to assign custom child tasks. + pg_timetable automatically names the base background chain after the job_name. +#} +DO $$ +DECLARE + v_chain_id bigint; +BEGIN + SELECT chain_id INTO v_chain_id + FROM timetable.chain + WHERE name = {{ data.name|qtLiteral }} + ORDER BY chain_id DESC LIMIT 1; + + {# Loop through the collection of tasks passed from our React multi-row grid #} + {% if data.tasks and data.tasks|length > 0 %} + {% for task in data.tasks %} + INSERT INTO timetable.task ( + chain_id, + task_order, + task_kind, + command + ) VALUES ( + v_chain_id, + {{ loop.index }}, + {{ task.task_kind|qtLiteral }}::timetable.task_kind, + {{ task.command|qtLiteral }} + ); + {% endfor %} + {% endif %} +END $$; + +COMMIT; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/delete.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/delete.sql new file mode 100644 index 00000000000..91c4121f449 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/delete.sql @@ -0,0 +1,13 @@ +{# Execute deletions safely within an isolated transaction block #} +BEGIN; + +{# 1. Purge dependent child tasks assigned to this target execution context #} +DELETE FROM timetable.task WHERE chain_id = {{ chain_id|int }}; + +{# 2. Clear any execution log histories tracking this specific scheduler block #} +DELETE FROM timetable.execution_log WHERE chain_id = {{ chain_id|int }}; + +{# 3. Safe termination of the master background orchestration node #} +DELETE FROM timetable.chain WHERE chain_id = {{ chain_id|int }}; + +COMMIT; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql new file mode 100644 index 00000000000..18f3dd0f6ee --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql @@ -0,0 +1,30 @@ +SELECT + c.chain_id AS id, + c.name, + c.run_at, + c.max_instances, + c.live, + {# Generate a clean JSON array of tasks matching the schema expectations #} + coalesce( + ( + SELECT json_agg( + json_build_object( + 'task_order', t.task_order, + 'task_kind', t.task_kind, + 'command', t.command + ) ORDER BY t.task_order ASC + ) + FROM timetable.task t + WHERE t.chain_id = c.chain_id + ), + '[]'::json + ) AS tasks +FROM + timetable.chain c +WHERE + {# If chain_id is omitted, this query returns all rows for the list view #} + {% if chain_id %} + c.chain_id = {{ chain_id|int }} + {% else %} + 1 = 1 + {% endif %}; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql new file mode 100644 index 00000000000..10fe11837c3 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql @@ -0,0 +1,46 @@ +{# Execute edits in an isolated block to prevent broken partial task steps #} +BEGIN; + +{# 1. Apply core parameter changes to the master chain row if any modifications occurred #} +{% if data.name or data.run_at or data.max_instances is defined or data.live is defined %} +UPDATE timetable.chain +SET + {% if data.name %} + name = {{ data.name|qtLiteral }}, + {% endif %} + {% if data.run_at %} + run_at = {{ data.run_at|qtLiteral }}, + {% endif %} + {% if data.max_instances is defined %} + max_instances = {{ data.max_instances|int }}, + {% endif %} + {% if data.live is defined %} + live = {{ data.live|lower }}, + {% endif %} + {# Remove trailing comma safely using standard Postgres syntax dummy assignment #} + chain_id = chain_id +WHERE chain_id = {{ chain_id|int }}; +{% endif %} + +{# 2. Re-align steps layout if the tasks grid configuration was edited #} +{% if data.tasks and data.tasks|length > 0 %} + {# Wipe pre-existing tasks assigned to this target id #} + DELETE FROM timetable.task WHERE chain_id = {{ chain_id|int }}; + + {# Insert the updated sequence list derived from the visual collection grid #} + {% for task in data.tasks %} + INSERT INTO timetable.task ( + chain_id, + task_order, + task_kind, + command + ) VALUES ( + {{ chain_id|int }}, + {{ loop.index }}, + {{ task.task_kind|qtLiteral }}::timetable.task_kind, + {{ task.command|qtLiteral }} + ); + {% endfor %} +{% endif %} + +COMMIT; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css new file mode 100644 index 00000000000..153abd7acd6 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css @@ -0,0 +1,24 @@ +/* Node icon displayed directly inside the left-hand navigation explorer tree */ +.icon-pg_timetable_chain { + background-image: none !important; +} + +.icon-pg_timetable_chain:before { + font-family: 'Font Awesome 5 Free', 'FontAwesome'; + font-weight: 900; + content: "\f017"; /* Unicode character string representing a clock/stopwatch icon */ + color: #2b6cb0; /* Theme-compliant dark blue tint matching server elements */ + font-size: 14px; + display: inline-block; +} + +/* Modifier state icon adjustment applied when the targeted chain is disabled or offline */ +.icon-pg_timetable_chain-disabled:before { + color: #a0aec0; /* Muted gray tint indicating an inactive cron state */ + content: "\f017"; +} + +/* Context menu header overlay utility styling */ +.ajs-content .icon-pg_timetable_chain:before { + padding-right: 8px; +} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js new file mode 100644 index 00000000000..b93217998de --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js @@ -0,0 +1,59 @@ +define([ + 'sources/pgadmin', + 'sources/gettext', + 'sources/browser/node', + './pg_timetable_schema.ui' // Imports the React UI Schema file we defined earlier +], function(pgAdmin, gettext, Node, PGTimetableChainSchema) { + + // 1. Prevent duplicate registrations if pgAdmin reloads the scripts + if (pgAdmin.Browser.Nodes['pg_timetable_chain']) { + return pgAdmin.Browser.Nodes['pg_timetable_chain']; + } + + // 2. Define the main browser node mirroring native pgAgent structures + var pgTimetableChainNode = pgAdmin.Browser.Nodes['pg_timetable_chain'] = Node.extend({ + type: 'pg_timetable_chain', + label: gettext('pg_timetable Chain'), + hasId: true, + parent_type: 'server', + canDrop: true, // Enables the "Delete/Drop" menu item + + // Configures context and upper tools menu layouts + menus: [{ + name: 'create_pg_timetable_chain', + module: this, + applies: ['tools', 'context'], + callback: 'show_obj_properties', + category: 'create', + priority: 4, + label: gettext('pg_timetable Chain...'), + icon: 'fa fa-plus', + enable: 'canCreate' + }], + + // 3. Dynamic icon evaluator tied directly to our custom CSS classes + icon: function(node_data) { + if (node_data && node_data.live === false) { + return 'icon-pg_timetable_chain-disabled'; + } + return 'icon-pg_timetable_chain'; + }, + + // 4. Verification checking if a server connection is open before enabling menus + canCreate: function(node) { + return node && node.connected; + }, + + // 5. Connects the UI to our React schema for building Create/Edit dialog forms + getSchema: function(treeInfo, itemData) { + return new PGTimetableChainSchema.default({ + role: treeInfo.server.user, + server_info: treeInfo.server + }); + } + }); + + return pgTimetableChainNode; +}); +// Inside your web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable.js node extension + diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js new file mode 100644 index 00000000000..38e6a9d9c09 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js @@ -0,0 +1,126 @@ +import BaseUISchema from 'sources/utils/BaseUISchema'; +import gettext from 'sources/gettext'; + +// 1. Define the child schema for individual tasks inside the chain array +class PGTimetableTaskSchema extends BaseUISchema { + constructor() { + super({ + task_kind: 'SQL', + command: '' + }); + } + + get baseFields() { + return [ + { + id: 'task_kind', + label: gettext('Kind'), + type: 'select', + control: 'select', + options: [ + { label: 'SQL Script', value: 'SQL' }, + { label: 'Shell / OS Command', value: 'PROGRAM' }, + { label: 'Built-In Boot Task', value: 'BUILTIN' } + ], + editable: true, + width: 150 + }, + { + id: 'command', + label: gettext('Script / Command Executable'), + type: 'textarea', + editable: true, + cell: 'string' + } + ]; + } +} + +// 2. Define the main schema for the master chain configuration +export default class PGTimetableChainSchema extends BaseUISchema { + constructor(fieldOptions = {}) { + super({ + id: undefined, + name: '', + run_at: '', + max_instances: 1, + live: true, + tasks: [] + }); + this.fieldOptions = fieldOptions; + } + + get baseFields() { + return [ + { + id: 'name', + label: gettext('Name'), + type: 'text', + mode: ['properties', 'create', 'edit'], + group: gettext('General'), + required: true + }, + { + id: 'live', + label: gettext('Enabled?'), + type: 'switch', + mode: ['properties', 'create', 'edit'], + group: gettext('General') + }, + { + id: 'max_instances', + label: gettext('Max Instances'), + type: 'int', + mode: ['properties', 'create', 'edit'], + group: gettext('General'), + min: 1 + }, + { + id: 'run_at', + label: gettext('Cron Expression'), + type: 'text', + mode: ['properties', 'create', 'edit'], + group: gettext('Schedules'), + placeholder: '* * * * *', + required: true + }, + { + id: 'tasks', + label: gettext('Tasks / Steps Sequence'), + type: 'collection', + mode: ['create', 'edit'], + group: gettext('Steps'), + schema: new PGTimetableTaskSchema(), // Nesting our row-level task schema here + canAdd: true, + canDelete: true, + canEdit: true, + uniqueCol : ['task_order'] + } + ]; + } + + // Frontend validation logic before sending data to create.sql / update.sql + validate(data, modifiedFields) { + let msg = null; + + if (!data.name || data.name.trim() === '') { + msg = gettext('Chain name cannot be empty.'); + this.setError('name', msg); + return true; + } + + if (!data.run_at || data.run_at.trim() === '') { + msg = gettext('Cron expression schedule is required.'); + this.setError('run_at', msg); + return true; + } + + if (!data.tasks || data.tasks.length === 0) { + msg = gettext('A chain must contain at least one step task.'); + this.setError('tasks', msg); + return true; + } + + return false; // Returns false if the form passes validation safely + } +} From 869d7bc0198d32a3819d1137a62763a6eb3dabf8 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Tue, 7 Jul 2026 15:36:53 -0400 Subject: [PATCH 02/19] pg_timetable plugin for pgAdmin patterned after pgAgent jobs WIP for #10148 - PGTimetable Chains node underneath Servers if pg_timetable is installed in maintenance database - Tasks node under Chain - Ability to add/edit a Chain - Ability to add/edit a Task under a Chain - Generate create sql script for a Chain - Ability to trigger Run Now for a change - Basic text editor for Task parameters and ability to add/edit - Icons for PGTime table to represent chains and tasks - Kind drop down list containing SQL, PROGRAM, BUILTIN - Program, drop down list if kind is "BUILTIN" - Auto increment order id of both task and parameters. Increment by 1 for parameters and 10 for tasks - Stats tab for both chain and chain task - Make database connection not required for SQL and note it defaults to pgTimeTable database - last_run, next_run, currently_running_on to the chain readonly properties screen - Regression tests for pg_timetable - Add cron help preserve layout with html pre so easy to read --- .../browser/server_groups/servers/__init__.py | 4 + .../servers/pg_timetable/__init__.py | 637 +++++++++++++++++- .../servers/pg_timetable/chains/__init__.py | 172 ----- .../chains/sql/default/check_extension.sql | 4 - .../chains/sql/default/create.sql | 46 -- .../chains/sql/default/properties.sql | 30 - .../chains/sql/default/update.sql | 46 -- .../pg_timetable/static/css/pg_timetable.css | 24 - .../pg_timetable/static/css/pgt_chain.css | 3 + .../static/img/coll-pgt_chain.svg | 11 + .../static/img/pgt_chain-disabled.svg | 14 + .../pg_timetable/static/img/pgt_chain.svg | 10 + .../pg_timetable/static/js/pg_timestable.js | 59 -- .../static/js/pg_timetable_schema.ui.js | 126 ---- .../pg_timetable/static/js/pgt_chain.js | 126 ++++ .../pg_timetable/static/js/pgt_chain.ui.js | 120 ++++ .../servers/pg_timetable/tasks/__init__.py | 613 +++++++++++++++++ .../tasks/static/img/coll-pgt_chaintask.svg | 45 ++ .../static/img/pgt_chaintask-disabled.svg | 32 + .../tasks/static/img/pgt_chaintask.svg | 28 + .../tasks/static/js/pgt_chaintask.js | 80 +++ .../tasks/static/js/pgt_chaintask.ui.js | 249 +++++++ .../pgt_chaintask/css/pgt_chaintask.css | 26 + .../pg_timetable/tasks/tests/__init__.py | 0 .../tasks/tests/tasks_test_data.json | 313 +++++++++ .../tasks/tests/test_pgt_task_add.py | 66 ++ .../tasks/tests/test_pgt_task_delete.py | 63 ++ .../tasks/tests/test_pgt_task_get.py | 68 ++ .../tasks/tests/test_pgt_task_get_msql.py | 51 ++ .../tasks/tests/test_pgt_task_get_nodes.py | 68 ++ .../tests/test_pgt_task_get_statistics.py | 61 ++ .../tasks/tests/test_pgt_task_put.py | 63 ++ .../servers/pg_timetable/tasks/tests/utils.py | 85 +++ .../templates/macros/pgt_chaintask.macros | 72 ++ .../templates/pgt_chain/css/pgt_chain.css | 26 + .../pgt_chain/sql/default/create.sql | 43 ++ .../pgt_chain}/sql/default/delete.sql | 0 .../templates/pgt_chain/sql/default/nodes.sql | 8 + .../pgt_chain/sql/default/properties.sql | 29 + .../pgt_chain/sql/default/run_now.sql | 8 + .../templates/pgt_chain/sql/default/stats.sql | 18 + .../templates/pgt_chain/sql/default/tasks.sql | 2 + .../pgt_chain/sql/default/update.sql | 13 + .../pgt_chaintask/css/pgt_chaintask.css | 26 + .../pgt_chaintask/sql/default/create.sql | 21 + .../pgt_chaintask/sql/default/delete.sql | 2 + .../pgt_chaintask/sql/default/nodes.sql | 11 + .../pgt_chaintask/sql/default/properties.sql | 2 + .../pgt_chaintask/sql/default/stats.sql | 19 + .../pgt_chaintask/sql/default/update.sql | 2 + .../servers/pg_timetable/tests/__init__.py | 15 + .../tests/pgt_timetable_test_data.json | 447 ++++++++++++ .../pg_timetable/tests/test_pgt_chain_add.py | 68 ++ .../tests/test_pgt_chain_delete.py | 57 ++ .../pg_timetable/tests/test_pgt_chain_get.py | 69 ++ .../tests/test_pgt_chain_get_msql.py | 48 ++ .../tests/test_pgt_chain_get_nodes.py | 68 ++ .../tests/test_pgt_chain_get_statistics.py | 55 ++ .../pg_timetable/tests/test_pgt_chain_put.py | 55 ++ .../pg_timetable/tests/test_pgt_chain_sql.py | 59 ++ .../servers/pg_timetable/tests/utils.py | 320 +++++++++ .../servers/pg_timetable/utils.py | 11 + web/webpack.config.js | 2 + web/webpack.shim.js | 2 + 64 files changed, 4382 insertions(+), 539 deletions(-) delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pgt_chain.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/coll-pgt_chain.svg create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain-disabled.svg create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain.svg delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/coll-pgt_chaintask.svg create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask-disabled.svg create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask.svg create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/template/pgt_chaintask/css/pgt_chaintask.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_msql.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/utils.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/css/pgt_chain.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/create.sql rename web/pgadmin/browser/server_groups/servers/pg_timetable/{chains => templates/pgt_chain}/sql/default/delete.sql (100%) create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/nodes.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/properties.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/stats.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/css/pgt_chaintask.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/delete.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/stats.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_add.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index cfcb324c4d8..237f107d9dc 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -389,6 +389,10 @@ def register(self, app, options): self.submodules.append(module) from .pgagent import blueprint as module + self.submodules.append(module) + + # Explicitly pull in your new testme package route + from .pg_timetable import blueprint as module self.submodules.append(module) from .resource_groups import blueprint as module diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index 8014c95a195..2d41a7e4563 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -7,49 +7,622 @@ # ########################################################################## -"""Implements the pg_timetable Jobs Node""" +"""Implements the pgTimeTable Chains Node""" +from functools import wraps +import json +from datetime import datetime, time -from flask_babelex import gettext -from pgadmin.browser.utils import PgAdminModule -from pgadmin.utils.ajax import make_json_response, internal_server_error +from flask import render_template, request, jsonify +from flask_babel import gettext as _ +from config import PG_DEFAULT_DRIVER -class PGTimetableModule(PgAdminModule): - def __init__(self, *args, **kwargs): - super(PGTimetableModule, self).__init__(*args, **kwargs) - self.submodules = [] +from pgadmin.browser.collection import CollectionNodeModule +from pgadmin.browser.utils import PGChildNodeView +from pgadmin.browser.server_groups import servers +from pgadmin.utils.ajax import make_json_response, internal_server_error, \ + make_response as ajax_response, gone, success_return +from pgadmin.utils.driver import get_driver +from pgadmin.utils.preferences import Preferences - def get_own_all_modules(self): - """Dynamically loads and initializes submodules like chains.""" - if not self.submodules: - from .chains import blueprint as chains_blueprint - self.submodules.append(chains_blueprint) - return self.submodules + +class ChainModule(CollectionNodeModule): + _NODE_TYPE = 'pgt_chain' + _COLLECTION_LABEL = _("pgTimeTable Chains") + + def get_nodes(self, gid, sid): + """ + Generate the collection node + """ + if self.show_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 + + def backend_supported(self, manager, **kwargs): + if hasattr(self, 'show_node') and not self.show_node: + return False + + conn = manager.connection() + + status, res = conn.execute_scalar(""" +SELECT + has_table_privilege( + 'timetable.chain', 'INSERT, SELECT, UPDATE' + ) has_priviledge +WHERE EXISTS( + SELECT has_schema_privilege('timetable', 'USAGE') + WHERE EXISTS( + SELECT cl.oid FROM pg_catalog.pg_class cl + LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid=relnamespace + WHERE relname='chain' AND nspname='timetable' + ) +) +""") + if status and res: + status, res = conn.execute_dict(""" +SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE + table_schema='timetable' AND table_name='task' AND + column_name='database_connection' + ) has_connstr""") + + manager.db_info['timetable'] = res['rows'][0] + return True + return False @property - def node_type(self): - return 'pg_timetable' + def csssnippets(self): + """ + Returns a snippet of css to include in the page + """ + snippets = [ + render_template( + self._COLLECTION_CSS, + node_type=self.node_type, + _=_ + ), + render_template( + "pgt_chain/css/pgt_chain.css", + node_type=self.node_type, + _=_ + ) + ] + + for submodule in self.submodules: + snippets.extend(submodule.csssnippets) + + return snippets @property - def label(self): - return gettext('pg_timetable') + def module_use_template_javascript(self): + """ + Returns whether Jinja2 template is used for generating the javascript + module. + """ + return False + + def register(self, app, options): + """ + Override the default register function to automagically register + sub-modules at once. + """ + from .tasks import blueprint as module + self.submodules.append(module) + + super().register(app, options) + + +blueprint = ChainModule(__name__) + + +class ChainView(PGChildNodeView): + node_type = blueprint.node_type + + parent_ids = [ + {'type': 'int', 'id': 'gid'}, + {'type': 'int', 'id': 'sid'} + ] + ids = [ + {'type': 'int', 'id': 'chain_id'} + ] + + operations = dict({ + 'obj': [ + {'get': 'properties', 'delete': 'delete', 'put': 'update'}, + {'get': 'properties', 'post': 'create', 'delete': 'delete'} + ], + 'nodes': [{'get': 'nodes'}, {'get': 'nodes'}], + 'sql': [{'get': 'sql'}], + 'msql': [{'get': 'msql'}, {'get': 'msql'}], + 'run_now': [{'put': 'run_now'}], + 'children': [{'get': 'children'}], + 'stats': [{'get': 'statistics'}] + }) + + 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(self, *args, **kwargs): + + self.manager = get_driver( + PG_DEFAULT_DRIVER + ).connection_manager( + kwargs['sid'] + ) + self.conn = self.manager.connection() + + # Set the template path for the sql scripts. + self.template_path = 'pgt_chain/sql/default' + + if 'timetable' not in self.manager.db_info: + _, res = self.conn.execute_dict(""" +SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE + table_schema='timetable' AND table_name='task' AND + column_name='database_connection' + ) has_connstr""") + + self.manager.db_info['timetable'] = res['rows'][0] + + return f(self, *args, **kwargs) + return wrap + + @check_precondition + def nodes(self, gid, sid, chain_id=None): + SQL = render_template( + "/".join([self.template_path, self._NODES_SQL]), + chain_id=chain_id, conn=self.conn + ) + status, rset = self.conn.execute_dict(SQL) + + if not status: + return internal_server_error(errormsg=rset) + + if chain_id is not None: + if len(rset['rows']) != 1: + return gone( + errormsg=_("Could not find the pgTimeTable chain on the server.") + ) + return make_json_response( + data=self.blueprint.generate_browser_node( + rset['rows'][0]['chain_id'], + sid, + rset['rows'][0]['chain_name'], + "icon-pgt_chain" if rset['rows'][0]['live'] else + "icon-pgt_chain-disabled", + description=rset['rows'][0]['chain_name'] + ), + status=200 + ) + + res = [] + for row in rset['rows']: + res.append( + self.blueprint.generate_browser_node( + row['chain_id'], + sid, + row['chain_name'], + "icon-pgt_chain" if row['live'] else + "icon-pgt_chain-disabled", + description=row['chain_name'] + ) + ) + + return make_json_response( + data=res, + status=200 + ) + + @check_precondition + def properties(self, gid, sid, chain_id=None): + SQL = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL]), + chain_id=chain_id, conn=self.conn + ) + status, rset = self.conn.execute_dict(SQL) + + if not status: + return internal_server_error(errormsg=rset) + + if chain_id is not None: + if len(rset['rows']) != 1: + return gone( + errormsg=_( + "Could not find the pgTimeTable chain on the server." + ) + ) + res = rset['rows'][0] + status, rset = self.conn.execute_dict( + render_template( + "/".join([self.template_path, 'tasks.sql']), + chain_id=chain_id, conn=self.conn, + has_connstr=self.manager.db_info['timetable']['has_connstr'] + ) + ) + if not status: + return internal_server_error(errormsg=rset) + res['ctasks'] = rset['rows'] + for task in res['ctasks']: + if isinstance(task.get('parameters'), str): + task['parameters'] = json.loads(task['parameters']) + else: + res = rset['rows'] + + return ajax_response( + response=res, + status=200 + ) - def get_own_stylesheets(self): - """Registers the custom CSS node icons.""" - return [ - 'pg_timetable/css/pg_timetable.css' + @check_precondition + def create(self, gid, sid): + """Create the pgAgent job.""" + required_args = [ + 'chain_name' ] - def get_own_javascripts(self): + data = request.form if request.form else json.loads( + request.data.decode('utf-8') + ) + + for arg in required_args: + if arg not in data: + return make_json_response( + status=410, + success=0, + errormsg=_( + "Could not find the required parameter ({})." + ).format(arg) + ) + + for task in data.get('ctasks', []): + if not isinstance(task, dict): + continue + cleaned_params = [] + for idx, param in enumerate(task.get('parameters', [])): + if not isinstance(param, dict): + cleaned_params.append({'order_id': idx + 1, 'value': str(param), '_is_json': False}) + else: + try: + json.loads(param.get('value', '')) + param['_is_json'] = True + except (ValueError, TypeError): + param['_is_json'] = False + cleaned_params.append(param) + if cleaned_params: + task['parameters'] = cleaned_params + + status, res = self.conn.execute_void('BEGIN') + if not status: + return internal_server_error(errormsg=res) + + status, res = self.conn.execute_scalar( + render_template( + "/".join([self.template_path, self._CREATE_SQL]), + data=data, conn=self.conn, fetch_id=True, + has_connstr=self.manager.db_info['timetable']['has_connstr'] + ) + ) + + if not status: + self.conn.execute_void('END') + return internal_server_error(errormsg=res) + + # We need oid of newly created database + status, res = self.conn.execute_dict( + render_template( + "/".join([self.template_path, self._NODES_SQL]), + chain_id=res, conn=self.conn + ) + ) + + self.conn.execute_void('END') + if not status: + return internal_server_error(errormsg=res) + + row = res['rows'][0] + + return jsonify( + node=self.blueprint.generate_browser_node( + row['chain_id'], + sid, + row['chain_name'], + icon="icon-pgt_chain" if row['live'] + else "icon-pgt_chain-disabled" + ) + ) + + @check_precondition + def update(self, gid, sid, chain_id): + """Update the pgTimeTable chain.""" + + data = request.form if request.form else json.loads( + request.data.decode('utf-8') + ) + + chain_fields = {k: data[k] for k in ['chain_name', 'live', 'max_instances', 'timeout', 'self_destruct', 'exclusive_execution', 'client_name', 'on_error', 'run_at'] if k in data} + if chain_fields: + sets = [] + params = [] + bool_keys = ['live', 'self_destruct', 'exclusive_execution'] + for key, val in chain_fields.items(): + sets.append(f"{key} = %s") + if key in bool_keys: + params.append('t' if val else 'f') + else: + params.append(val) + params.append(chain_id) + sql = f"UPDATE timetable.chain SET {', '.join(sets)} WHERE chain_id = %s" + status, res = self.conn.execute_void(sql, params) + if not status: + return internal_server_error(errormsg=res) + + self._process_ctasks(chain_id, data.get('ctasks', {})) + + status, res = self.conn.execute_dict( + render_template( + "/".join([self.template_path, self._NODES_SQL]), + chain_id=chain_id, conn=self.conn + ) + ) + + if not status: + return internal_server_error(errormsg=res) + + row = res['rows'][0] + + return jsonify( + node=self.blueprint.generate_browser_node( + chain_id, + sid, + row['chain_name'], + icon="icon-pgt_chain" if row['live'] + else "icon-pgt_chain-disabled", + description=row['chain_name'] + ) + ) + + def _process_ctasks(self, chain_id, ctasks): + if not isinstance(ctasks, dict): + return + + for task in ctasks.get('deleted', []): + tid = task.get('task_id') if isinstance(task, dict) else task + if tid: + self.conn.execute_void( + "DELETE FROM timetable.task WHERE task_id = %s AND chain_id = %s", + (tid, chain_id) + ) + + for task in ctasks.get('changed', []): + if not isinstance(task, dict): + continue + tid = task.get('task_id') + if not tid: + continue + sets = [] + params = [] + field_map = { + 'task_name': 'task_name', + 'task_order': 'task_order', + 'command': 'command', + 'database_connection': 'database_connection', + 'ignore_error': 'ignore_error', + } + if 'kind' in task: + sets.append("kind = %s::timetable.command_kind") + params.append(task['kind']) + for frontend_field, db_field in field_map.items(): + if frontend_field in task: + sets.append(f"{db_field} = %s") + params.append(task[frontend_field]) + if sets: + params.extend([tid, chain_id]) + sql = f"UPDATE timetable.task SET {', '.join(sets)} WHERE task_id = %s AND chain_id = %s" + self.conn.execute_void(sql, params) + if 'parameters' in task: + self._upsert_task_params(tid, task['parameters']) + + has_connstr = self.manager.db_info['timetable']['has_connstr'] + for task in ctasks.get('added', []): + if not isinstance(task, dict): + continue + fields = ['chain_id', 'task_name', 'task_order', 'command'] + values = [chain_id, task.get('task_name', ''), task.get('task_order', 10), task.get('command', '')] + if 'ignore_error' in task: + fields.append('ignore_error') + values.append(task['ignore_error']) + if 'kind' in task: + fields.append('kind') + values.append(task['kind']) + if has_connstr and 'database_connection' in task and task['database_connection']: + fields.append('database_connection') + values.append(task['database_connection']) + placeholders = ', '.join(['%s'] * len(values)) + sql = f"INSERT INTO timetable.task ({', '.join(fields)}) VALUES ({placeholders}) RETURNING task_id" + status, tid = self.conn.execute_scalar(sql, values) + if status and tid: + self._upsert_task_params(tid, task.get('parameters', [])) + + def _upsert_task_params(self, task_id, parameters): + if not parameters: + return + if isinstance(parameters, dict): + parameters = parameters.get('added', []) + parameters.get('changed', []) + if not parameters: + return + self.conn.execute_void( + "DELETE FROM timetable.parameter WHERE task_id = %s", (task_id,) + ) + for idx, param in enumerate(parameters): + if not isinstance(param, dict): + param = {'order_id': idx + 1, 'value': str(param)} + order_id = param.get('order_id') + if order_id is None: + order_id = idx + 1 + val = param.get('value', '') + if val is None: + val = '' + try: + json.loads(val) + sql = "INSERT INTO timetable.parameter(task_id, order_id, value) VALUES (%s, %s, %s::jsonb)" + params = (task_id, order_id, val) + except (ValueError, TypeError): + sql = "INSERT INTO timetable.parameter(task_id, order_id, value) VALUES (%s, %s, to_jsonb(%s::text))" + params = (task_id, order_id, val) + self.conn.execute_void(sql, params) + + @check_precondition + def delete(self, gid, sid, chain_id=None): + """Delete the pgAgent Job.""" + + if chain_id is None: + data = request.form if request.form else json.loads( + request.data + ) + else: + data = {'ids': [chain_id]} + + for chain_id in data['ids']: + status, res = self.conn.execute_void( + render_template( + "/".join([self.template_path, self._DELETE_SQL]), + chain_id=chain_id, conn=self.conn + ) + ) + if not status: + return internal_server_error(errormsg=res) + + return make_json_response(success=1) + + @check_precondition + def msql(self, gid, sid, chain_id=None): + """ + This function to return modified SQL. + """ + data = {} + for k, v in request.args.items(): + try: + data[k] = json.loads( + v.decode('utf-8') if hasattr(v, 'decode') else v + ) + except ValueError: + data[k] = v + + return make_json_response( + data=render_template( + "/".join([ + self.template_path, + self._CREATE_SQL if chain_id is None else self._UPDATE_SQL + ]), + chain_id=chain_id, data=data, conn=self.conn, fetch_id=False, + has_connstr=self.manager.db_info['timetable']['has_connstr'] + ), + status=200 + ) + + @check_precondition + def statistics(self, gid, sid, chain_id): """ - Registers the front-end JS files. This tells pgAdmin's script loader - to pull in your node definition file automatically on application load. + statistics + Returns the statistics for a particular database if chain_id is specified, + otherwise it will return statistics for all the databases in that + server. """ - return [{ - 'name': 'pg_timetable', - 'path': 'pg_timetable/js/pg_timetable', - 'when': None # Loads unconditionally during application boot - }] + pref = Preferences.module('browser') + rows_threshold = pref.preference( + 'pgagent_row_threshold' + ) + + status, res = self.conn.execute_dict( + render_template( + "/".join([self.template_path, 'stats.sql']), + chain_id=chain_id, conn=self.conn, + rows_threshold=rows_threshold.get() + ) + ) + + if not status: + return internal_server_error(errormsg=res) + + return make_json_response( + data=res, + status=200 + ) + + @check_precondition + def sql(self, gid, sid, chain_id): + """ + This function will generate sql for sql panel + """ + SQL = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL]), + chain_id=chain_id, conn=self.conn, last_system_oid=0 + ) + status, res = self.conn.execute_dict(SQL) + if not status: + return internal_server_error(errormsg=res) + + if len(res['rows']) == 0: + return gone( + _("Could not find the object on the server.") + ) + + row = res['rows'][0] + + status, res = self.conn.execute_dict( + render_template( + "/".join([self.template_path, 'tasks.sql']), + chain_id=chain_id, conn=self.conn, + has_connstr=self.manager.db_info['timetable']['has_connstr'] + ) + ) + if not status: + return internal_server_error(errormsg=res) + + row['ctasks'] = res['rows'] + for task in row['ctasks']: + if isinstance(task.get('parameters'), str): + task['parameters'] = json.loads(task['parameters']) + + return ajax_response( + response=render_template( + "/".join([self.template_path, self._CREATE_SQL]), + chain_id=chain_id, data=row, conn=self.conn, fetch_id=False, + has_connstr=self.manager.db_info['timetable']['has_connstr'] + ) + ) + + @check_precondition + def run_now(self, gid, sid, chain_id): + """ + This function will set the next run to now, to inform the pgAgent to + run the job now. + """ + status, res = self.conn.execute_void( + render_template( + "/".join([self.template_path, 'run_now.sql']), + chain_id=chain_id, conn=self.conn + ) + ) + if not status: + return internal_server_error(errormsg=res) + + return success_return( + message=_("Updated the next runtime to now.") + ) -# Initialize the main extension blueprint -blueprint = PGTimetableModule('pg_timetable', __name__, static_folder='static') +ChainView.register_node_view(blueprint) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py deleted file mode 100644 index 8acdceec91f..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py +++ /dev/null @@ -1,172 +0,0 @@ - -from pgadmin.browser.plugins import BrowserPluginModule -from pgadmin.browser.utils import NodeView -from pgadmin.utils.ajax import make_json_response, internal_server_error -from pgadmin.utils.driver import get_driver -from pgadmin.utils.constants import MANAGED_PROVIDER -from flask import request - -# 1. The Browser Module (registers the node infrastructure in pgAdmin) -class PGTimetableChainModule(BrowserPluginModule): - def __init__(self, *args, **kwargs): - super(PGTimetableChainModule, self).__init__(*args, **kwargs) - self.node_type = 'pg_timetable_chain' - self.parent_type = 'server' - - def get_nodes(self, gid, sid): - """Generates visual child tree items under the active Server connection.""" - pass - - -# Initialize the module instance for pgAdmin's plugin loader -blueprint = PGTimetableChainModule('pg_timetable_chain', __name__, static_folder='static') - - -# 2. The Node View (maps REST API endpoints to this specific node type) -class PGTimetableChainView(NodeView): - node_type = 'pg_timetable_chain' - parent_ids = [{'type': 'server', 'id': 'sid'}] - ids = [{'type': 'pg_timetable_chain', 'id': 'chain_id'}] - - def _get_connection(self, sid): - """Helper to get a connection pointing to the maintenance DB.""" - driver = get_driver(MANAGED_PROVIDER) - manager = driver.connection_manager(sid) - conn = manager.connection(manager.db) - return driver, conn - - def list(self, gid, sid): - """Triggered automatically when clicking the pg_timetable folder node.""" - try: - driver, conn = self._get_connection(sid) - - # Check if pg_timetable schema is present - sql_path = 'pg_timetable/chains/sql/default/check_extension.sql' - status, res = conn.execute_scalar(self.render_template_file(sql_path)) - - if not res: - return make_json_response(data=[]) # Hide gracefully if not installed - - # Fetch active cron chains - sql = "SELECT chain_id as id, name FROM timetable.chain;" - status, result = conn.execute_dict(sql) - - nodes = [] - for row in result['rows']: - nodes.append(self.blueprint.generate_browser_node( - id=row['id'], - label=row['name'], - icon='icon-pg_timetable_chain' - )) - return make_json_response(data=nodes) - except Exception as e: - return internal_server_error(errormsg=str(e)) - - def create(self, gid, sid): - """Invoked when a POST request hits the node endpoint with form data.""" - try: - # Read JSON payload sent by the React frontend - data = request.get_json() - driver, conn = self._get_connection(sid) - - # Render and execute the template file - sql = self.render_template_file( - 'pg_timetable/chains/sql/default/create.sql', - data=data - ) - status, result = conn.execute_dict(sql) - - # Fetch the ID of the freshly generated object to return to the UI tree - id_sql = f"SELECT chain_id FROM timetable.chain WHERE name = {driver.qtLiteral(data['name'])} ORDER BY chain_id DESC LIMIT 1;" - status, id_res = conn.execute_dict(id_sql) - new_id = id_res['rows'][0]['chain_id'] - - return make_json_response( - data=self.blueprint.generate_browser_node( - id=new_id, - label=data['name'], - icon='icon-pg_timetable_chain' - ) - ) - except Exception as e: - # Make sure to rollback if things fail mid-transaction - return internal_server_error(errormsg=str(e)) - - def update(self, gid, sid, chain_id): - """Invoked automatically when a PUT request hits the matching node instance.""" - try: - # Parse form edits sent down from the client model layer - data = request.get_json() - driver, conn = self._get_connection(sid) - - # Compile and push structural edits through the database driver connection - sql = self.render_template_file( - 'pg_timetable/chains/sql/default/update.sql', - data=data, - chain_id=chain_id - ) - status, result = conn.execute_dict(sql) - - # Fetch the updated name to verify if the sidebar tree label needs a rename event - current_name = data.get('name') - if not current_name: - name_sql = f"SELECT name FROM timetable.chain WHERE chain_id = {int(chain_id)};" - status, name_res = conn.execute_dict(name_sql) - current_name = name_res['rows'][0]['name'] - - return make_json_response( - data=self.blueprint.generate_browser_node( - id=chain_id, - label=current_name, - icon='icon-pg_timetable_chain' - ) - ) - except Exception as e: - # Prevent data contamination if validation fails midway through operations - return internal_server_error(errormsg=str(e)) - - def properties(self, gid, sid, chain_id): - """Invoked when pgAdmin requests data to fill the properties dashboard panel.""" - try: - driver, conn = self._get_connection(sid) - - # Render script using the specific clicked chain_id parameter - sql = self.render_template_file( - 'pg_timetable/chains/sql/default/properties.sql', - chain_id=chain_id - ) - status, result = conn.execute_dict(sql) - - if not result['rows']: - return internal_server_error(errormsg="Requested pg_timetable chain not found.") - - # Grab the row dictionary mapping directly to your React schema structure - data = result['rows'][0] - - return make_json_response(data=data) - except Exception as e: - return internal_server_error(errormsg=str(e)) - - def delete(self, gid, sid, chain_id): - """Invoked automatically when a user hits Delete/Drop on a tree node selection.""" - try: - driver, conn = self._get_connection(sid) - - # Compile parameters and push structural deletions to the cluster - sql = self.render_template_file( - 'pg_timetable/chains/sql/default/delete.sql', - chain_id=chain_id - ) - status, result = conn.execute_dict(sql) - - # Return a success flag response to notify the frontend to remove the node - return make_json_response( - success=True, - info="pg_timetable Chain successfully dropped." - ) - except Exception as e: - # Safe rollback operation triggers implicitly on driver connection breakdown - return internal_server_error(errormsg=str(e)) - -# 3. Connect the View routing back to the Module Blueprint -PGTimetableChainView.register_node_view(blueprint) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql deleted file mode 100644 index 5ced2b0609e..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql +++ /dev/null @@ -1,4 +0,0 @@ -{# Check if pg_timetable schema exists in the current connected database #} -SELECT EXISTS ( - SELECT 1 FROM pg_namespace WHERE nspname = 'timetable' -) AS has_timetable; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql deleted file mode 100644 index ab4c07f3846..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql +++ /dev/null @@ -1,46 +0,0 @@ -{# Create a transaction to ensure both the chain and tasks are added safely #} -BEGIN; - -{# 1. Register the base chain/job with the timetable system #} -SELECT timetable.add_job( - job_name => {{ data.name|qtLiteral }}, - job_schedule => {{ data.run_at|qtLiteral }}, - job_command => NULL, {# We leave this NULL because we add separate step tasks below #} - job_kind => 'SQL'::timetable.task_kind, - job_client_name => NULL, - job_max_instances => {{ data.max_instances|default(1, true)|int }}, - job_live => {{ data.live|lower }} -); - -{# - 2. Target the newly created job id to assign custom child tasks. - pg_timetable automatically names the base background chain after the job_name. -#} -DO $$ -DECLARE - v_chain_id bigint; -BEGIN - SELECT chain_id INTO v_chain_id - FROM timetable.chain - WHERE name = {{ data.name|qtLiteral }} - ORDER BY chain_id DESC LIMIT 1; - - {# Loop through the collection of tasks passed from our React multi-row grid #} - {% if data.tasks and data.tasks|length > 0 %} - {% for task in data.tasks %} - INSERT INTO timetable.task ( - chain_id, - task_order, - task_kind, - command - ) VALUES ( - v_chain_id, - {{ loop.index }}, - {{ task.task_kind|qtLiteral }}::timetable.task_kind, - {{ task.command|qtLiteral }} - ); - {% endfor %} - {% endif %} -END $$; - -COMMIT; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql deleted file mode 100644 index 18f3dd0f6ee..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql +++ /dev/null @@ -1,30 +0,0 @@ -SELECT - c.chain_id AS id, - c.name, - c.run_at, - c.max_instances, - c.live, - {# Generate a clean JSON array of tasks matching the schema expectations #} - coalesce( - ( - SELECT json_agg( - json_build_object( - 'task_order', t.task_order, - 'task_kind', t.task_kind, - 'command', t.command - ) ORDER BY t.task_order ASC - ) - FROM timetable.task t - WHERE t.chain_id = c.chain_id - ), - '[]'::json - ) AS tasks -FROM - timetable.chain c -WHERE - {# If chain_id is omitted, this query returns all rows for the list view #} - {% if chain_id %} - c.chain_id = {{ chain_id|int }} - {% else %} - 1 = 1 - {% endif %}; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql deleted file mode 100644 index 10fe11837c3..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql +++ /dev/null @@ -1,46 +0,0 @@ -{# Execute edits in an isolated block to prevent broken partial task steps #} -BEGIN; - -{# 1. Apply core parameter changes to the master chain row if any modifications occurred #} -{% if data.name or data.run_at or data.max_instances is defined or data.live is defined %} -UPDATE timetable.chain -SET - {% if data.name %} - name = {{ data.name|qtLiteral }}, - {% endif %} - {% if data.run_at %} - run_at = {{ data.run_at|qtLiteral }}, - {% endif %} - {% if data.max_instances is defined %} - max_instances = {{ data.max_instances|int }}, - {% endif %} - {% if data.live is defined %} - live = {{ data.live|lower }}, - {% endif %} - {# Remove trailing comma safely using standard Postgres syntax dummy assignment #} - chain_id = chain_id -WHERE chain_id = {{ chain_id|int }}; -{% endif %} - -{# 2. Re-align steps layout if the tasks grid configuration was edited #} -{% if data.tasks and data.tasks|length > 0 %} - {# Wipe pre-existing tasks assigned to this target id #} - DELETE FROM timetable.task WHERE chain_id = {{ chain_id|int }}; - - {# Insert the updated sequence list derived from the visual collection grid #} - {% for task in data.tasks %} - INSERT INTO timetable.task ( - chain_id, - task_order, - task_kind, - command - ) VALUES ( - {{ chain_id|int }}, - {{ loop.index }}, - {{ task.task_kind|qtLiteral }}::timetable.task_kind, - {{ task.command|qtLiteral }} - ); - {% endfor %} -{% endif %} - -COMMIT; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css deleted file mode 100644 index 153abd7acd6..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css +++ /dev/null @@ -1,24 +0,0 @@ -/* Node icon displayed directly inside the left-hand navigation explorer tree */ -.icon-pg_timetable_chain { - background-image: none !important; -} - -.icon-pg_timetable_chain:before { - font-family: 'Font Awesome 5 Free', 'FontAwesome'; - font-weight: 900; - content: "\f017"; /* Unicode character string representing a clock/stopwatch icon */ - color: #2b6cb0; /* Theme-compliant dark blue tint matching server elements */ - font-size: 14px; - display: inline-block; -} - -/* Modifier state icon adjustment applied when the targeted chain is disabled or offline */ -.icon-pg_timetable_chain-disabled:before { - color: #a0aec0; /* Muted gray tint indicating an inactive cron state */ - content: "\f017"; -} - -/* Context menu header overlay utility styling */ -.ajs-content .icon-pg_timetable_chain:before { - padding-right: 8px; -} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pgt_chain.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pgt_chain.css new file mode 100644 index 00000000000..af8a30b228f --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pgt_chain.css @@ -0,0 +1,3 @@ +div[role=tabpanel] > .pgadmin-control-group.form-group.c.jscexceptions { + min-height: 400px; +} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/coll-pgt_chain.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/coll-pgt_chain.svg new file mode 100644 index 00000000000..4214d98c1df --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/coll-pgt_chain.svg @@ -0,0 +1,11 @@ + + + + + coll-pgt_chain + + + diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain-disabled.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain-disabled.svg new file mode 100644 index 00000000000..b140d734d16 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain-disabled.svg @@ -0,0 +1,14 @@ + + + + + pgt_chain-disabled + + + + + + diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain.svg new file mode 100644 index 00000000000..df4d423cdeb --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain.svg @@ -0,0 +1,10 @@ + + + + + pgt_chain + + diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js deleted file mode 100644 index b93217998de..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js +++ /dev/null @@ -1,59 +0,0 @@ -define([ - 'sources/pgadmin', - 'sources/gettext', - 'sources/browser/node', - './pg_timetable_schema.ui' // Imports the React UI Schema file we defined earlier -], function(pgAdmin, gettext, Node, PGTimetableChainSchema) { - - // 1. Prevent duplicate registrations if pgAdmin reloads the scripts - if (pgAdmin.Browser.Nodes['pg_timetable_chain']) { - return pgAdmin.Browser.Nodes['pg_timetable_chain']; - } - - // 2. Define the main browser node mirroring native pgAgent structures - var pgTimetableChainNode = pgAdmin.Browser.Nodes['pg_timetable_chain'] = Node.extend({ - type: 'pg_timetable_chain', - label: gettext('pg_timetable Chain'), - hasId: true, - parent_type: 'server', - canDrop: true, // Enables the "Delete/Drop" menu item - - // Configures context and upper tools menu layouts - menus: [{ - name: 'create_pg_timetable_chain', - module: this, - applies: ['tools', 'context'], - callback: 'show_obj_properties', - category: 'create', - priority: 4, - label: gettext('pg_timetable Chain...'), - icon: 'fa fa-plus', - enable: 'canCreate' - }], - - // 3. Dynamic icon evaluator tied directly to our custom CSS classes - icon: function(node_data) { - if (node_data && node_data.live === false) { - return 'icon-pg_timetable_chain-disabled'; - } - return 'icon-pg_timetable_chain'; - }, - - // 4. Verification checking if a server connection is open before enabling menus - canCreate: function(node) { - return node && node.connected; - }, - - // 5. Connects the UI to our React schema for building Create/Edit dialog forms - getSchema: function(treeInfo, itemData) { - return new PGTimetableChainSchema.default({ - role: treeInfo.server.user, - server_info: treeInfo.server - }); - } - }); - - return pgTimetableChainNode; -}); -// Inside your web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable.js node extension - diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js deleted file mode 100644 index 38e6a9d9c09..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js +++ /dev/null @@ -1,126 +0,0 @@ -import BaseUISchema from 'sources/utils/BaseUISchema'; -import gettext from 'sources/gettext'; - -// 1. Define the child schema for individual tasks inside the chain array -class PGTimetableTaskSchema extends BaseUISchema { - constructor() { - super({ - task_kind: 'SQL', - command: '' - }); - } - - get baseFields() { - return [ - { - id: 'task_kind', - label: gettext('Kind'), - type: 'select', - control: 'select', - options: [ - { label: 'SQL Script', value: 'SQL' }, - { label: 'Shell / OS Command', value: 'PROGRAM' }, - { label: 'Built-In Boot Task', value: 'BUILTIN' } - ], - editable: true, - width: 150 - }, - { - id: 'command', - label: gettext('Script / Command Executable'), - type: 'textarea', - editable: true, - cell: 'string' - } - ]; - } -} - -// 2. Define the main schema for the master chain configuration -export default class PGTimetableChainSchema extends BaseUISchema { - constructor(fieldOptions = {}) { - super({ - id: undefined, - name: '', - run_at: '', - max_instances: 1, - live: true, - tasks: [] - }); - this.fieldOptions = fieldOptions; - } - - get baseFields() { - return [ - { - id: 'name', - label: gettext('Name'), - type: 'text', - mode: ['properties', 'create', 'edit'], - group: gettext('General'), - required: true - }, - { - id: 'live', - label: gettext('Enabled?'), - type: 'switch', - mode: ['properties', 'create', 'edit'], - group: gettext('General') - }, - { - id: 'max_instances', - label: gettext('Max Instances'), - type: 'int', - mode: ['properties', 'create', 'edit'], - group: gettext('General'), - min: 1 - }, - { - id: 'run_at', - label: gettext('Cron Expression'), - type: 'text', - mode: ['properties', 'create', 'edit'], - group: gettext('Schedules'), - placeholder: '* * * * *', - required: true - }, - { - id: 'tasks', - label: gettext('Tasks / Steps Sequence'), - type: 'collection', - mode: ['create', 'edit'], - group: gettext('Steps'), - schema: new PGTimetableTaskSchema(), // Nesting our row-level task schema here - canAdd: true, - canDelete: true, - canEdit: true, - uniqueCol : ['task_order'] - } - ]; - } - - // Frontend validation logic before sending data to create.sql / update.sql - validate(data, modifiedFields) { - let msg = null; - - if (!data.name || data.name.trim() === '') { - msg = gettext('Chain name cannot be empty.'); - this.setError('name', msg); - return true; - } - - if (!data.run_at || data.run_at.trim() === '') { - msg = gettext('Cron expression schedule is required.'); - this.setError('run_at', msg); - return true; - } - - if (!data.tasks || data.tasks.length === 0) { - msg = gettext('A chain must contain at least one step task.'); - this.setError('tasks', msg); - return true; - } - - return false; // Returns false if the form passes validation safely - } -} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js new file mode 100644 index 00000000000..8cc44e7a06f --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js @@ -0,0 +1,126 @@ +/////////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +/////////////////////////////////////////////////////////////// + +import PgtChainSchema from './pgt_chain.ui'; +import { getNodePgtChainTaskSchema } from '../../tasks/static/js/pgt_chaintask.ui'; +import getApiInstance from '../../../../../../static/js/api_instance'; +import pgAdmin from 'sources/pgadmin'; + +define('pgadmin.node.pgt_chain', [ + 'sources/gettext', 'sources/url_for', 'pgadmin.browser', + 'pgadmin.node.pgt_chaintask', +], function(gettext, url_for, pgBrowser) { + if (!pgBrowser.Nodes['coll-pgt_chain']) { + pgBrowser.Nodes['coll-pgt_chain'] = pgBrowser.Collection.extend({ + node: 'pgt_chain', + label: gettext('pgt_chains'), + type: 'coll-pgt_chain', + columns: ['chain_id', 'chain_name', 'live', 'returncode', 'run_at'], + hasStatistics: false, + canDrop: true, + canDropCascade: false, + }); + } + + if (!pgBrowser.Nodes['pgt_chain']) { + pgBrowser.Nodes['pgt_chain'] = pgBrowser.Node.extend({ + parent_type: 'server', + type: 'pgt_chain', + dialogHelp: url_for('help.static', { filename: 'pgtimetable_chains.html' }), + hasSQL: true, + hasDepends: false, + hasStatistics: true, + hasCollectiveStatistics: true, + width: '80%', + height: '80%', + canDrop: true, + label: gettext('pgTimeTable Chain'), + node_image: function () { + return 'icon-pgt_chain'; + }, + Init: function () { + if (this.initialized) { + return; + } + this.initialized = true; + }, + + getSchema: function (treeNodeInfo, itemNodeData) { + return new PgtChainSchema({}, () => getNodePgtChainTaskSchema(treeNodeInfo, itemNodeData)); + }, + + run_pgt_chain_now: function (args) { + var input = args || {}; + var obj = this; + var t = pgBrowser.tree; + var i = input.item || t.selected(); + var d = i ? t.itemData(i) : undefined; + if (d) { + getApiInstance() + .put(obj.generate_url(i, 'run_now', d, true)) + .then(({ data: res }) => { + pgAdmin.Browser.notifier.success(res.info); + t.unload(i); + }) + .catch(function (error) { + pgAdmin.Browser.notifier.pgRespErrorNotify(error); + t.unload(i); + }); + } + return false; + }, + }); + } + pgBrowser.add_menus([ + { + name: 'create_pgt_chain_on_server', + node: 'server', + module: pgBrowser.Nodes['pgt_chain'], + applies: ['object', 'context'], + callback: 'show_obj_properties', + category: 'create', + priority: 4, + label: gettext('pgTimeTable Chain...'), + data: { action: 'create' }, + }, + { + name: 'create_pgt_chain_on_coll', + node: 'coll-pgt_chain', + module: pgBrowser.Nodes['pgt_chain'], + applies: ['object', 'context'], + callback: 'show_obj_properties', + category: 'create', + priority: 4, + label: gettext('pgTimeTable Chain...'), + data: { action: 'create' }, + }, + { + name: 'create_pgt_chain', + node: 'pgt_chain', + module: pgBrowser.Nodes['pgt_chain'], + applies: ['object', 'context'], + callback: 'show_obj_properties', + category: 'create', + priority: 4, + label: gettext('pgTimeTable Chain...'), + data: { action: 'create' }, + }, + { + name: 'run_now_pgt_chain', + node: 'pgt_chain', + module: pgBrowser.Nodes['pgt_chain'], + applies: ['object', 'context'], + callback: 'run_pgt_chain_now', + priority: 4, + label: gettext('Run now'), + data: { action: 'create' }, + }, + ]); + return pgBrowser.Nodes['pgt_chain']; +}); diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js new file mode 100644 index 00000000000..4f5824540c3 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js @@ -0,0 +1,120 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, 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 { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView'; + +export default class PgtChainSchema extends BaseUISchema { + constructor(fieldOptions={}, getPgtChainTaskSchema=()=>[], initValues={}) { + super({ + chain_name: '', + chain_id: undefined, + live: true, + max_instances: null, + timeout: 0, + self_destruct: false, + exclusive_execution: false, + client_name: '', + on_error: null, + ctasks: [], + run_at: '', + ...initValues, + }); + + this.fieldOptions = { + ...fieldOptions, + }; + this.getPgtChainTaskSchema = getPgtChainTaskSchema; + } + + get idAttribute() { + return 'chain_id'; + } + + get baseFields() { + return [ + { + id: 'chain_name', label: gettext('Name'), type: 'text', noEmpty: true, + },{ + id: 'chain_id', label: gettext('ID'), mode: ['properties'], + type: 'int', + },{ + id: 'live', label: gettext('Enabled?'), type: 'switch', + },{ + id: 'max_instances', label: gettext('Max instances'), type: 'int', + helpMessage: gettext('Number of instances (clients) this chain can run in parallel. Leave blank for no limit.'), + helpMessageMode: ['edit', 'create'], + },{ + id: 'timeout', label: gettext('Timeout (ms)'), type: 'int', + helpMessage: gettext('Abort any chain that takes more than the specified number of milliseconds. 0 means no timeout.'), + helpMessageMode: ['edit', 'create'], + },{ + id: 'self_destruct', label: gettext('Self-destruct?'), type: 'switch', + helpMessage: gettext('If enabled, this chain will delete itself after a successful run.'), + helpMessageMode: ['edit', 'create'], + },{ + id: 'exclusive_execution', label: gettext('Exclusive execution?'), type: 'switch', + helpMessage: gettext('If enabled, all parallel chains will be paused while executing this chain.'), + helpMessageMode: ['edit', 'create'], + },{ + id: 'client_name', label: gettext('Host agent'), type: 'text', + helpMessage: gettext('Enter the client name of a machine running pgTimetable if you wish to ensure only that machine will run this job. Leave blank if any host may run the job.'), + helpMessageMode: ['edit', 'create'], + },{ + id: 'on_error', label: gettext('On error'), type: 'text', + helpMessage: gettext('Action to take on error. NULL=default error handling, LOG=log and continue, ABORT=abort chain.'), + helpMessageMode: ['edit', 'create'], + },{ + id: 'run_at', label: gettext('Schedule'), type: 'text', + helpMessage: gettext(`
----CRON-Style
+-- * * * * * command to execute
+-- ┬ ┬ ┬ ┬ ┬
+-- │ │ │ │ │
+-- │ │ │ │ └──── day of the week (0 - 7) (Sunday to Saturday)(0 and 7 is Sunday);
+-- │ │ │ └────── month (1 - 12)
+-- │ │ └──────── day of the month (1 - 31)
+-- │ └────────── hour (0 - 23)
+-- └──────────── minute (0 - 59)
`), + helpMessageMode: ['create', 'edit'], + },{ + id: 'last_run', label: gettext('Last run'), type: 'text', + mode: ['properties'], + },{ + id: 'finished', label: gettext('Finished'), type: 'text', + mode: ['properties'], + },{ + id: 'duration', label: gettext('Duration'), type: 'text', + mode: ['properties'], + },{ + id: 'next_run', label: gettext('Next run'), type: 'text', + mode: ['properties'], + },{ + id: 'currently_running_on', label: gettext('Currently running on'), type: 'text', + mode: ['properties'], + },{ + id: 'ctasks', label: '', group: gettext('Tasks'), + type: 'collection', mode: ['edit', 'create'], + schema: this.getPgtChainTaskSchema(), + canEdit: true, canAdd: true, canDelete: true, + columns: [ + 'task_name', 'task_order', 'kind', 'ignore_error', + ], + depChange: (state, source, topState, actionObj) => { + if (actionObj.type === SCHEMA_STATE_ACTIONS.ADD_ROW && state?.ctasks) { + const tasks = state.ctasks; + const lastOrder = tasks.reduce((max, t) => Math.max(max, t.task_order || 0), 0); + tasks[tasks.length - 1].task_order = lastOrder + 10; + } + return state; + }, + } + ]; + } +} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py new file mode 100644 index 00000000000..7d4940c813d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -0,0 +1,613 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Implements pgAgent Job Step Node""" + +import json +from functools import wraps + +from flask import render_template, request, jsonify +from flask_babel import gettext +from pgadmin.browser.collection import CollectionNodeModule +from pgadmin.browser.utils import PGChildNodeView +from pgadmin.utils.ajax import make_json_response, gone, \ + make_response as ajax_response, internal_server_error +from pgadmin.utils.driver import get_driver +from pgadmin.utils.preferences import Preferences + +from config import PG_DEFAULT_DRIVER + + +class ChainTaskModule(CollectionNodeModule): + """ + class ChainTaskModule(CollectionNodeModule) + + A module class for ChainTask node derived from CollectionNodeModule. + + Methods: + ------- + * get_nodes(gid, sid, chain_id) + - Method is used to generate the browser collection node. + + * node_inode() + - Method is overridden from its base class to make the node as leaf node. + """ + + _NODE_TYPE = 'pgt_chaintask' + _COLLECTION_LABEL = gettext("Tasks") + + def get_nodes(self, gid, sid, chain_id): + """ + Method is used to generate the browser collection node + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Database Id + """ + yield self.generate_browser_collection_node(chain_id) + + @property + def node_inode(self): + """ + Override this property to make the node a leaf node. + + Returns: False as this is the leaf node + """ + return False + + @property + def script_load(self): + """ + Load the module script for language, when any of the pga_job nodes + are initialized. + + Returns: node type of the server module. + """ + return 'pgt_chain' + + @property + def csssnippets(self): + """ + Returns a snippet of css to include in the page + """ + snippets = [ + render_template( + "pgt_chaintask/css/pgt_chaintask.css", + node_type=self.node_type + ) + ] + + for submodule in self.submodules: + snippets.extend(submodule.csssnippets) + + return snippets + + @property + def module_use_template_javascript(self): + """ + Returns whether Jinja2 template is used for generating the javascript + module. + """ + return False + + +blueprint = ChainTaskModule(__name__) + + +class ChainTaskView(PGChildNodeView): + """ + class ChainTaskView(PGChildNodeView) + + A view class for ChainTask node derived from PGChildNodeView. + This class is responsible for all the stuff related to view like + updating job step node, showing properties, showing sql in sql pane. + + Methods: + ------- + * __init__(**kwargs) + - Method is used to initialize the ChainTaskView and it's base view. + + * check_precondition() + - 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 + + * list() + - This function is used to list all the job step nodes within that + collection. + + * nodes() + - This function will used to create all the child node within that + collection. + Here it will create all the job step node. + + * properties(gid, sid, chain_id, task_id) + - This function will show the properties of the selected job step node + + * update(gid, sid, chain_id, task_id) + - This function will update the data for the selected job step node + + * msql(gid, sid, chain_id, task_id) + - This function is used to return modified SQL for the selected + job step node + + * sql(gid, sid, chain_id, jscid) + - Dummy response for sql panel + + * delete(gid, sid, chain_id, jscid) + - Drops job step + """ + + node_type = blueprint.node_type + STEP_NOT_FOUND = "Could not find the specified chain task." + + parent_ids = [ + {'type': 'int', 'id': 'gid'}, + {'type': 'int', 'id': 'sid'}, + {'type': 'int', 'id': 'chain_id'} + ] + ids = [ + {'type': 'int', 'id': 'task_id'} + ] + + operations = dict({ + 'obj': [ + {'get': 'properties', 'put': 'update', 'delete': 'delete'}, + {'get': 'list', 'post': 'create', 'delete': 'delete'} + ], + 'nodes': [{'get': 'nodes'}, {'get': 'nodes'}], + 'msql': [{'get': 'msql'}, {'get': 'msql'}], + 'sql': [{'get': 'sql'}], + 'stats': [{'get': 'statistics'}] + }) + + def _init_(self, **kwargs): + """ + Method is used to initialize the ChainTaskView and its base view. + Initialize all the variables create/used dynamically like conn, + template_path. + + Args: + **kwargs: + """ + self.conn = None + self.template_path = None + self.manager = None + + super().__init__(**kwargs) + + def check_precondition(f): + """ + This function will behave as a decorator which will check the + database connection before running the view. It 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,chain_id + self = args[0] + self.driver = get_driver(PG_DEFAULT_DRIVER) + self.manager = self.driver.connection_manager(kwargs['sid']) + self.conn = self.manager.connection() + + self.template_path = 'pgt_chaintask/sql/default' + + if 'timetable' not in self.manager.db_info: + _, res = self.conn.execute_dict(""" +SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE + table_schema='timetable' AND table_name='task' AND + column_name='database_connection' + ) has_connstr""") + + self.manager.db_info['timetable'] = res['rows'][0] + + return f(*args, **kwargs) + + return wrap + + @check_precondition + def list(self, gid, sid, chain_id): + """ + This function is used to list all the job step nodes within + that collection. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job ID + """ + sql = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL]), + chain_id=chain_id, + has_connstr=self.manager.db_info['timetable']['has_connstr'], + conn=self.conn + ) + status, res = self.conn.execute_dict(sql) + + if not status: + return internal_server_error(errormsg=res) + + for task in res['rows']: + if isinstance(task.get('parameters'), str): + task['parameters'] = json.loads(task['parameters']) + + return ajax_response( + response=res['rows'], + status=200 + ) + + @check_precondition + def nodes(self, gid, sid, chain_id, task_id=None): + """ + This function is used to create all the child nodes + within the collection. + Here it will create all the job step nodes. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job ID + """ + res = [] + sql = render_template( + "/".join([self.template_path, self._NODES_SQL]), + task_id=task_id, + chain_id=chain_id, + conn=self.conn + ) + + status, result = self.conn.execute_2darray(sql) + + if not status: + return internal_server_error(errormsg=result) + + if task_id is not None: + if len(result['rows']) == 0: + return gone(errormsg=self.STEP_NOT_FOUND) + + row = result['rows'][0] + return make_json_response( + self.blueprint.generate_browser_node( + row['task_id'], + row['chain_id'], + row['task_name'], + icon="icon-pgt_chaintask", + enabled=True, + kind=row['kind'], + description=row['task_name'] + ) + ) + + for row in result['rows']: + res.append( + self.blueprint.generate_browser_node( + row['task_id'], + row['chain_id'], + row['task_name'], + icon="icon-pgt_chaintask", + enabled=True, + kind=row['kind'], + description=row['task_name'] + ) + ) + + return make_json_response( + data=res, + status=200 + ) + + @check_precondition + def properties(self, gid, sid, chain_id, task_id): + """ + This function will show the properties of the selected job step node. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job ID + task_id: ChainTask ID + """ + sql = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL]), + task_id=task_id, + chain_id=chain_id, + has_connstr=self.manager.db_info['timetable']['has_connstr'], + 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(errormsg=self.STEP_NOT_FOUND) + + task = res['rows'][0] + if isinstance(task.get('parameters'), str): + task['parameters'] = json.loads(task['parameters']) + + return ajax_response( + response=task, + status=200 + ) + + @check_precondition + def create(self, gid, sid, chain_id): + """ + This function will update the data for the selected job step node. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job ID + """ + data = {} + if request.args: + for k, v in request.args.items(): + try: + data[k] = json.loads( + v.decode('utf-8') if hasattr(v, 'decode') else v + ) + except ValueError: + data[k] = v + else: + data = json.loads(request.data.decode()) + + if 'parameters' in data: + params_raw = data.get('parameters', []) + if isinstance(params_raw, dict): + params_raw = params_raw.get('added', []) + params_raw.get('changed', []) + cleaned = [] + for idx, param in enumerate(params_raw): + if not isinstance(param, dict): + cleaned.append({'order_id': idx + 1, 'value': str(param), '_is_json': False}) + else: + try: + json.loads(param.get('value', '')) + param['_is_json'] = True + except (ValueError, TypeError): + param['_is_json'] = False + cleaned.append(param) + data['parameters'] = cleaned + + sql = render_template( + "/".join([self.template_path, self._CREATE_SQL]), + chain_id=chain_id, + data=data, + has_connstr=self.manager.db_info['timetable']['has_connstr'], + conn=self.conn + ) + + status, res = self.conn.execute_scalar(sql) + + if not status: + return internal_server_error(errormsg=res) + + sql = render_template( + "/".join([self.template_path, self._NODES_SQL]), + task_id=res, + chain_id=chain_id, + conn=self.conn + ) + status, res = self.conn.execute_2darray(sql) + + if not status: + return internal_server_error(errormsg=res) + + if len(res['rows']) == 0: + return gone( + errormsg=gettext( + "Job step creation failed." + ) + ) + row = res['rows'][0] + return jsonify( + node=self.blueprint.generate_browser_node( + row['task_id'], + row['chain_id'], + row['task_name'], + icon="icon-pgt_chaintask" + ) + ) + + @check_precondition + def update(self, gid, sid, chain_id, task_id): + """ + This function will update the data for the selected job step node. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job ID + task_id: ChainTask ID + """ + data = request.form if request.form else json.loads( + request.data.decode('utf-8') + ) + + if 'parameters' in data: + params_raw = data.get('parameters', []) + if isinstance(params_raw, dict): + params_raw = params_raw.get('added', []) + params_raw.get('changed', []) + cleaned = [] + for idx, param in enumerate(params_raw): + if not isinstance(param, dict): + cleaned.append({'order_id': idx + 1, 'value': str(param), '_is_json': False}) + else: + try: + json.loads(param.get('value', '')) + param['_is_json'] = True + except (ValueError, TypeError): + param['_is_json'] = False + cleaned.append(param) + data['parameters'] = cleaned + + sql = render_template( + "/".join([self.template_path, self._UPDATE_SQL]), + chain_id=chain_id, + task_id=task_id, + data=data, + has_connstr=self.manager.db_info['timetable']['has_connstr'], + conn=self.conn + ) + + status, res = self.conn.execute_void(sql) + + if not status: + return internal_server_error(errormsg=res) + + sql = render_template( + "/".join([self.template_path, self._NODES_SQL]), + task_id=task_id, + chain_id=chain_id, + conn=self.conn + ) + status, res = self.conn.execute_2darray(sql) + + if not status: + return internal_server_error(errormsg=res) + + if len(res['rows']) == 0: + return gone( + errormsg=gettext( + "Job step update failed." + ) + ) + row = res['rows'][0] + return jsonify( + node=self.blueprint.generate_browser_node( + task_id, + chain_id, + row['task_name'], + icon="icon-pgt_chaintask", + description=row['task_name'] + ) + ) + + @check_precondition + def delete(self, gid, sid, chain_id, task_id=None): + """Delete the Job step.""" + + if task_id is None: + data = request.form if request.form else json.loads( + request.data + ) + else: + data = {'ids': [task_id]} + + for task_id in data['ids']: + status, res = self.conn.execute_void( + render_template( + "/".join([self.template_path, self._DELETE_SQL]), + chain_id=chain_id, task_id=task_id, conn=self.conn + ) + ) + if not status: + return internal_server_error(errormsg=res) + + return make_json_response(success=1) + + @check_precondition + def msql(self, gid, sid, chain_id, task_id=None): + """ + This function is used to return modified SQL for the selected + job step node. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job ID + task_id: Job Step ID + """ + data = {} + sql = '' + for k, v in request.args.items(): + try: + data[k] = json.loads(v) + except ValueError: + data[k] = v + + if task_id is None: + sql = render_template( + "/".join([self.template_path, self._CREATE_SQL]), + chain_id=chain_id, + data=data, + has_connstr=self.manager.db_info['timetable']['has_connstr'], + conn=self.conn + ) + + return make_json_response( + data=sql, + status=200 + ) + + sql = render_template( + "/".join([self.template_path, self._UPDATE_SQL]), + chain_id=chain_id, + task_id=task_id, + data=data, + has_connstr=self.manager.db_info['timetable']['has_connstr'], + conn=self.conn + ) + + return make_json_response( + data=sql, + status=200 + ) + + @check_precondition + def statistics(self, gid, sid, chain_id, task_id): + """ + statistics + Returns the statistics for a particular database if chain_id is specified, + otherwise it will return statistics for all the databases in that + server. + """ + pref = Preferences.module('browser') + rows_threshold = pref.preference( + 'pgagent_row_threshold' + ) + + status, res = self.conn.execute_dict( + render_template( + "/".join([self.template_path, 'stats.sql']), + chain_id=chain_id, task_id=task_id, conn=self.conn, + rows_threshold=rows_threshold.get() + ) + ) + + if not status: + return internal_server_error(errormsg=res) + + return make_json_response( + data=res, + status=200 + ) + + @check_precondition + def sql(self, gid, sid, chain_id, task_id): + """ + Dummy response for sql route. + As we need to have msql tab for create and edit mode we can not + disable it setting hasSQL=false because we have a single 'hasSQL' + flag in JS to display both sql & msql tab + """ + return ajax_response( + response=gettext( + "-- No SQL could be generated for the selected object." + ), + status=200 + ) + + +ChainTaskView.register_node_view(blueprint) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/coll-pgt_chaintask.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/coll-pgt_chaintask.svg new file mode 100644 index 00000000000..54c4b73ed32 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/coll-pgt_chaintask.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask-disabled.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask-disabled.svg new file mode 100644 index 00000000000..71aafb8b009 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask-disabled.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask.svg new file mode 100644 index 00000000000..27692ead9f4 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js new file mode 100644 index 00000000000..dbe6cc6e72b --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js @@ -0,0 +1,80 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { getNodePgtChainTaskSchema } from './pgt_chaintask.ui'; + +define('pgadmin.node.pgt_chaintask', [ + 'sources/gettext', 'sources/url_for', 'pgadmin.browser', +], function(gettext, url_for, pgBrowser) { + + if (!pgBrowser.Nodes['coll-pgt_chaintask']) { + pgBrowser.Nodes['coll-pgt_chaintask'] = + pgBrowser.Collection.extend({ + node: 'pgt_chaintask', + label: gettext('Tasks'), + type: 'coll-pgt_chaintask', + columns: [ + 'task_id', 'task_name', 'kind', + 'ignore_error', + ], + hasStatistics: false, + canDropCascade: false, + }); + } + + if (!pgBrowser.Nodes['pgt_chaintask']) { + pgBrowser.Nodes['pgt_chaintask'] = pgBrowser.Node.extend({ + parent_type: 'pgt_chain', + type: 'pgt_chaintask', + dialogHelp: url_for('help.static', {'filename': 'pgtimetable_chains.html'}), + hasSQL: true, + hasDepends: false, + hasStatistics: true, + hasCollectiveStatistics: true, + width: '70%', + height: '80%', + canDrop: true, + label: gettext('Task'), + node_image: function() { + return 'icon-pgt_chaintask'; + }, + Init: function() { + /* Avoid mulitple registration of menus */ + if (this.initialized) + return; + + this.initialized = true; + + pgBrowser.add_menus([{ + name: 'create_pgt_chaintask_on_job', node: 'pgt_chain', module: this, + applies: ['object', 'context'], callback: 'show_obj_properties', + category: 'create', priority: 4, label: gettext('Chain Task...'), + data: {'action': 'create'}, + },{ + name: 'create_pgt_chaintask_on_coll', node: 'coll-pgt_chaintask', module: this, + applies: ['object', 'context'], callback: 'show_obj_properties', + category: 'create', priority: 4, label: gettext('Chain Task...'), + data: {'action': 'create'}, + },{ + name: 'create_pgt_chaintask', node: 'pgt_chaintask', module: this, + applies: ['object', 'context'], callback: 'show_obj_properties', + category: 'create', priority: 4, label: gettext('Chain Task...'), + data: {'action': 'create'}, + }]); + }, + + getSchema: function(treeNodeInfo, itemNodeData) { + return getNodePgtChainTaskSchema(treeNodeInfo, itemNodeData); + }, + + }); + } + + return pgBrowser.Nodes['pgt_chaintask']; +}); diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js new file mode 100644 index 00000000000..27f36514c7f --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js @@ -0,0 +1,249 @@ +////////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, 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 { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView'; +import { getNodeListByName } from '../../../../../../static/js/node_ajax'; +import { isEmptyString } from 'sources/validators'; + +export function getNodePgtChainTaskSchema(treeNodeInfo, itemNodeData) { + const paramSchema = new (class extends BaseUISchema { + constructor() { + super({ order_id: null, value: '' }); + } + get idAttribute() { return 'order_id'; } + get baseFields() { + return [ + { id: 'order_id', label: gettext('Order'), type: 'int', noEmpty: true, cell: 'int', width: 20 }, + { id: 'value', label: gettext('Value'), type: 'multiline', cell: 'text' }, + ]; + } + validate(state, setError) { + if (!state.order_id || state.order_id < 1) { + setError('order_id', gettext('Order must be a positive integer.')); + return true; + } + setError('order_id', null); + if (isEmptyString(state.value)) { + setError('value', gettext('Please enter a parameter value.')); + return true; + } + setError('value', null); + } + })(); + + return new PgtChainTaskSchema( + { + databases: () => + getNodeListByName('database', treeNodeInfo, itemNodeData, { + cacheLevel: 'database', + cacheNode: 'database', + }), + paramSchema, + }, + { + jstdbname: treeNodeInfo['server']['db'], + } + ); +} + +export default class PgtChainTaskSchema extends BaseUISchema { + constructor(fieldOptions = {}, initValues = {}) { + super({ + task_id: null, + chain_id: null, + task_name: '', + task_order: 10, + kind: 'SQL', + command: '', + database_connection: null, + ignore_error: false, + parameters: [], + ...initValues, + }); + + this.fieldOptions = { + databases: [], + paramSchema: new (class extends BaseUISchema { + constructor() { + super({ order_id: null, value: '' }); + } + get idAttribute() { return 'order_id'; } + get baseFields() { + return [ + { id: 'order_id', label: gettext('Order'), type: 'int', noEmpty: true, cell: 'int', width: 20 }, + { id: 'value', label: gettext('Value'), type: 'multiline', cell: 'text' }, + ]; + } + })(), + ...fieldOptions, + }; + } + + get idAttribute() { + return 'task_id'; + } + + get baseFields() { + return [ + { + id: 'task_id', + label: gettext('ID'), + type: 'int', + mode: ['properties'], + }, + { + id: 'task_name', + label: gettext('Name'), + type: 'text', + noEmpty: true, + cell: 'text', + }, + { + id: 'task_order', + label: gettext('Order'), + type: 'int', + cell: 'int', + }, + { + id: 'kind', + label: gettext('Kind'), + type: 'select', + controlProps: { allowClear: false }, + cell: 'select', + options: [ + { label: gettext('SQL'), value: 'SQL' }, + { label: gettext('PROGRAM'), value: 'PROGRAM' }, + { label: gettext('BUILTIN'), value: 'BUILTIN' }, + ], + }, + { + id: 'database_connection', + label: gettext('Connection string'), + type: 'text', + deps: ['kind'], + disabled: (state) => state.kind !== 'SQL', + helpMessage: `Optional connection string for the database server. Leave blank to use the pgTimetable database. Each parameter setting is in the form keyword = value. Spaces around the equal sign are optional. To write an empty value, or a value containing spaces, surround it with single quotes, e.g., keyword = 'a value'. Single quotes and backslashes within the value must be escaped with a backslash, i.e., ' and \\. +For more information, please see the documentation on libpq connection strings.`, + helpMessageMode: ['edit', 'create'], + }, + { + id: 'ignore_error', + label: gettext('Ignore error'), + type: 'switch', + cell: 'switch', + }, + { + id: 'command', + label: gettext('Command'), + type: (state) => { + if (state?.kind === 'SQL') return { type: 'multiline', label: gettext('SQL') }; + if (state?.kind === 'BUILTIN') return { type: 'select', label: gettext('Internal Command'), options: [ + { label: 'NoOp', value: 'NoOp' }, + { label: 'Sleep', value: 'Sleep' }, + { label: 'Log', value: 'Log' }, + { label: 'SendMail', value: 'SendMail' }, + { label: 'Download', value: 'Download' }, + { label: 'CopyFromFile', value: 'CopyFromFile' }, + { label: 'CopyToFile', value: 'CopyToFile' }, + { label: 'Shutdown', value: 'Shutdown' }, + ] }; + return { type: 'text', label: gettext('Program') }; + }, + group: gettext('Code'), + deps: ['kind'], + }, + { + id: 'parameters', label: '', group: gettext('Code'), + type: 'collection', mode: ['edit', 'create'], + deps: ['kind'], + schema: this.fieldOptions.paramSchema, + canEdit: true, canAdd: true, canDelete: true, + columns: ['order_id', 'value'], + depChange: (state, source, topState, actionObj) => { + if (actionObj.type === SCHEMA_STATE_ACTIONS.ADD_ROW && state?.parameters) { + const params = state.parameters; + const lastOrder = params.reduce((max, p) => Math.max(max, p.order_id || 0), 0); + params[params.length - 1].order_id = lastOrder + 1; + } + return state; + }, + }, + ]; + } + + validate(state, setError) { + let errMsg = null; + + if (state.kind === 'SQL') { + const r = /\s*\b(\w+)\s*=\s*('([^'\\]*(?:\\.[^'\\]*)*)'|[\w|\.]*)/g; + if (!isEmptyString(state.database_connection)) { + if (String(state.database_connection).replace(r, '') !== '') { + setError('database_connection', gettext('Please enter a valid connection string.')); + return true; + } else { + const params = { + host: true, + hostaddr: true, + port: true, + dbname: true, + user: true, + password: true, + connect_timeout: true, + client_encoding: true, + application_name: true, + options: true, + fallback_application_name: true, + sslmode: true, + sslcert: true, + sslkey: true, + sslrootcert: true, + sslcrl: true, + keepalives: true, + service: true, + keepalives_idle: true, + keepalives_interval: true, + keepalives_count: true, + sslcompression: true, + requirepeer: true, + krbsrvname: true, + gsslib: true, + }; + let m; + while ((m = r.exec(state.database_connection))) { + if (params[m[1]]) { + if (m[2]) continue; + errMsg = gettext('Please enter a valid connection string.'); + break; + } + errMsg = gettext('Invalid parameter in the connection string - %s.', m[1]); + break; + } + } + + if (errMsg) { + setError('database_connection', errMsg); + return true; + } else { + setError('database_connection', null); + } + } else { + setError('database_connection', null); + } + + if (isEmptyString(state.command)) { + setError('command', state.kind === 'SQL' ? gettext('Please specify the SQL to execute.') : gettext('Please specify the program to execute.')); + return true; + } else { + setError('command', null); + } + } + } +} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/template/pgt_chaintask/css/pgt_chaintask.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/template/pgt_chaintask/css/pgt_chaintask.css new file mode 100644 index 00000000000..339ae85c290 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/template/pgt_chaintask/css/pgt_chaintask.css @@ -0,0 +1,26 @@ +.icon-pgt_chaintask { + background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/pgt_chaintask.svg') }}') !important; + background-repeat: no-repeat; + background-size: 20px !important; + align-content: center; + vertical-align: middle; + height: 1.3em; +} + +.icon-pgt_chaintask-disabled { + background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/pgt_chaintask-disabled.svg') }}') !important; + background-repeat: no-repeat; + background-size: 20px !important; + align-content: center; + vertical-align: middle; + height: 1.3em; +} + +.icon-coll-pgt_chaintask { + background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/coll-pgt_chaintask.svg') }}') !important; + background-repeat: no-repeat; + background-size: 20px !important; + align-content: center; + vertical-align: middle; + height: 1.3em; +} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json new file mode 100644 index 00000000000..bcf809a184b --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json @@ -0,0 +1,313 @@ +{ + "pgt_task_create": [ + { + "name": "Create task: With valid data.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "task_name": "test_task_add%s", + "task_order": 10, + "kind": "SQL", + "command": "SELECT 1;", + "ignore_error": false + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Create task: With parameters.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "task_name": "test_task_add_params%s", + "task_order": 10, + "kind": "SQL", + "command": "SELECT $1::text AS param;", + "ignore_error": false, + "parameters": [ + { + "order_id": 1, + "value": "test_value" + } + ] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Create task: With existing chain while server is down.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": { + "task_name": "test_task_add%s", + "task_order": 10, + "kind": "SQL", + "command": "SELECT 1;" + }, + "mocking_required": true, + "mock_data": { + "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_scalar", + "return_value": "[(False,'Mocked Internal Server Error')]" + }, + "expected_data": { + "status_code": 500, + "error_msg": "Mocked Internal Server Error", + "test_result_data": {} + } + } + ], + "pgt_task_delete": [ + { + "name": "Delete task: With existing task.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": false + }, + { + "name": "Delete tasks: With existing tasks.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": true + } + ], + "pgt_task_put": [ + { + "name": "Update task: With updated command.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "command": "SELECT 2;" + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Update task: Disable ignore_error.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "ignore_error": true + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Update task: With parameters.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "command": "SELECT $1::text;", + "parameters": [ + { + "order_id": 1, + "value": "updated_value" + } + ] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Update task: With existing task while server is down.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": { + "command": "SELECT 2;" + }, + "mocking_required": true, + "mock_data": { + "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_void", + "return_value": "(False,'Mocked Internal Server Error')" + }, + "expected_data": { + "status_code": 500, + "error_msg": "Mocked Internal Server Error", + "test_result_data": {} + } + } + ], + "pgt_task_get": [ + { + "name": "Get task: With existing task.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": false + }, + { + "name": "Get tasks: With existing tasks.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": true + }, + { + "name": "Get task: With existing task while server is down.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": {}, + "mocking_required": true, + "mock_data": { + "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict", + "return_value": "(False,'Mocked Internal Server Error')" + }, + "expected_data": { + "status_code": 500, + "error_msg": "Mocked Internal Server Error", + "test_result_data": {} + }, + "is_list": false + } + ], + "pgt_task_get_nodes": [ + { + "name": "Get task nodes: With existing task.", + "url": "/browser/pgt_chaintask/nodes/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": false + }, + { + "name": "Get tasks nodes: With existing tasks.", + "url": "/browser/pgt_chaintask/nodes/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": true + } + ], + "pgt_task_msql": [ + { + "name": "Get task msql: For existing task.", + "url": "/browser/pgt_chaintask/msql/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "command": "SELECT 2;" + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": false + } + ], + "pgt_task_get_statistics": [ + { + "name": "Get task stats: With existing task.", + "url": "/browser/pgt_chaintask/stats/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Get task stats: With existing task while server is down.", + "url": "/browser/pgt_chaintask/stats/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": {}, + "mocking_required": true, + "mock_data": { + "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict", + "return_value": "(False,'Mocked Internal Server Error')" + }, + "expected_data": { + "status_code": 500, + "error_msg": "Mocked Internal Server Error", + "test_result_data": {} + } + } + ] +} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py new file mode 100644 index 00000000000..0711de1df89 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py @@ -0,0 +1,66 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## +from unittest.mock import patch + +import json +import uuid +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from pgadmin.browser.server_groups.servers.pg_timetable.tests import \ + utils as pgt_utils +from . import utils as tasks_utils + + +class PgtTaskAddTestCase(BaseTestGenerator): + """This class will test the add task in the pgTimetable chain API""" + scenarios = utils.generate_scenarios("pgt_task_create", + tasks_utils.test_cases) + + def setUp(self): + super().setUp() + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_add%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + def runTest(self): + self.pgt_task_name = "test_task_add%s" % str(uuid.uuid4())[1:8] + + if "task_name" in self.data: + self.data["task_name"] = self.pgt_task_name + + if self.is_positive_test: + response = tasks_utils.api_create(self) + + utils.assert_status_code(self, response) + + response_data = json.loads(response.data) + self.task_id = response_data['node']['_id'] + is_present = pgt_utils.verify_pgtimetable_task(self) + self.assertTrue(is_present, + "pgTimetable task was not created successfully.") + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=eval(self.mock_data["return_value"])): + response = tasks_utils.api_create(self) + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py new file mode 100644 index 00000000000..09178761d74 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py @@ -0,0 +1,63 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from pgadmin.browser.server_groups.servers.pg_timetable.tests import \ + utils as pgt_utils +from . import utils as tasks_utils + + +class PgtTaskDeleteTestCase(BaseTestGenerator): + """This class will test the delete pgTimetable chain task API""" + scenarios = utils.generate_scenarios("pgt_task_delete", + tasks_utils.test_cases) + + def setUp(self): + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_delete%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + task_name = "test_task_delete%s" % str(uuid.uuid4())[1:8] + self.task_id = pgt_utils.create_pgtimetable_task( + self, task_name, self.chain_id) + + if self.is_list: + task_name2 = "test_task2_delete%s" % str(uuid.uuid4())[1:8] + self.task_id_2 = pgt_utils.create_pgtimetable_task( + self, task_name2, self.chain_id) + + def runTest(self): + """This function will delete pgTimetable chain task""" + if self.is_positive_test: + if self.is_list: + self.data['ids'] = [self.task_id, self.task_id_2] + response = tasks_utils.api_delete(self, '') + else: + response = tasks_utils.api_delete(self) + + utils.assert_status_code(self, response) + + is_present = pgt_utils.verify_pgtimetable_task(self) + self.assertFalse( + is_present, + "pgTimetable task was not deleted successfully") + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py new file mode 100644 index 00000000000..9e8a15bc749 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py @@ -0,0 +1,68 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from unittest.mock import patch + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from pgadmin.browser.server_groups.servers.pg_timetable.tests import \ + utils as pgt_utils +from . import utils as tasks_utils + + +class PgtTaskGetTestCase(BaseTestGenerator): + """This class will test the get pgTimetable chain task API""" + scenarios = utils.generate_scenarios("pgt_task_get", + tasks_utils.test_cases) + + def setUp(self): + super().setUp() + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_get%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + task_name = "test_task_get%s" % str(uuid.uuid4())[1:8] + self.task_id = pgt_utils.create_pgtimetable_task( + self, task_name, self.chain_id) + + if self.is_list: + task_name2 = "test_task_get%s" % str(uuid.uuid4())[1:8] + self.task_id_2 = pgt_utils.create_pgtimetable_task( + self, task_name2, self.chain_id) + + def runTest(self): + """This function will get pgTimetable chain task""" + if self.is_positive_test: + if self.is_list: + response = tasks_utils.api_get(self, '') + else: + response = tasks_utils.api_get(self) + + utils.assert_status_code(self, response) + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])]): + response = tasks_utils.api_get(self) + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_msql.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_msql.py new file mode 100644 index 00000000000..d5c6f96206e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_msql.py @@ -0,0 +1,51 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from pgadmin.browser.server_groups.servers.pg_timetable.tests import \ + utils as pgt_utils +from . import utils as tasks_utils + + +class PgtTaskGetMsqlTestCase(BaseTestGenerator): + """This class will test the msql pgTimetable chain task API""" + scenarios = utils.generate_scenarios("pgt_task_msql", + tasks_utils.test_cases) + + def setUp(self): + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_msql%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + task_name = "test_task_msql%s" % str(uuid.uuid4())[1:8] + self.task_id = pgt_utils.create_pgtimetable_task( + self, task_name, self.chain_id) + + def runTest(self): + """This function will get pgTimetable msql chain task""" + if self.is_positive_test: + url_encode_data = self.data + response = tasks_utils.api_get_msql(self, url_encode_data) + + utils.assert_status_code(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py new file mode 100644 index 00000000000..dfd6cb98b86 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py @@ -0,0 +1,68 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from unittest.mock import patch + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from pgadmin.browser.server_groups.servers.pg_timetable.tests import \ + utils as pgt_utils +from . import utils as tasks_utils + + +class PgtTaskGetNodesTestCase(BaseTestGenerator): + """This class will test the get nodes pgTimetable chain task API""" + scenarios = utils.generate_scenarios("pgt_task_get_nodes", + tasks_utils.test_cases) + + def setUp(self): + super().setUp() + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_get_nodes%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + task_name = "test_task_get_nodes%s" % str(uuid.uuid4())[1:8] + self.task_id = pgt_utils.create_pgtimetable_task( + self, task_name, self.chain_id) + + if self.is_list: + task_name2 = "test_task_get_nodes%s" % str(uuid.uuid4())[1:8] + self.task_id_2 = pgt_utils.create_pgtimetable_task( + self, task_name2, self.chain_id) + + def runTest(self): + """This function will get pgTimetable chain task nodes""" + if self.is_positive_test: + if self.is_list: + response = tasks_utils.api_get(self, '') + else: + response = tasks_utils.api_get(self) + + utils.assert_status_code(self, response) + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])]): + response = tasks_utils.api_get(self) + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py new file mode 100644 index 00000000000..34e4d596e66 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py @@ -0,0 +1,61 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from unittest.mock import patch + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from pgadmin.browser.server_groups.servers.pg_timetable.tests import \ + utils as pgt_utils +from . import utils as tasks_utils + + +class PgtTaskGetStatsTestCase(BaseTestGenerator): + """This class will test the get pgTimetable chain task stats API""" + scenarios = utils.generate_scenarios("pgt_task_get_statistics", + tasks_utils.test_cases) + + def setUp(self): + super().setUp() + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_get_stats%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + task_name = "test_task_get_stats%s" % str(uuid.uuid4())[1:8] + self.task_id = pgt_utils.create_pgtimetable_task( + self, task_name, self.chain_id) + + def runTest(self): + """This function will get pgTimetable chain task stats""" + + if self.is_positive_test: + response = tasks_utils.api_get_stats(self) + + utils.assert_status_code(self, response) + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])]): + response = tasks_utils.api_get_stats(self) + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py new file mode 100644 index 00000000000..e75e291b68e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py @@ -0,0 +1,63 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from unittest.mock import patch + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from pgadmin.browser.server_groups.servers.pg_timetable.tests import \ + utils as pgt_utils +from . import utils as tasks_utils + + +class PgtTaskPutTestCase(BaseTestGenerator): + """This class will test the update pgTimetable chain task API""" + scenarios = utils.generate_scenarios("pgt_task_put", + tasks_utils.test_cases) + + def setUp(self): + super().setUp() + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_update%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + task_name = "test_task_update%s" % str(uuid.uuid4())[1:8] + self.task_id = pgt_utils.create_pgtimetable_task( + self, task_name, self.chain_id) + + def runTest(self): + """This function will update pgTimetable chain task""" + + self.data['task_id'] = str(self.task_id) + + if self.is_positive_test: + response = tasks_utils.api_put(self) + + utils.assert_status_code(self, response) + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])]): + response = tasks_utils.api_put(self) + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/utils.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/utils.py new file mode 100644 index 00000000000..9e8a0931b6f --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/utils.py @@ -0,0 +1,85 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import os +import json +from urllib.parse import urlencode + +from regression.python_test_utils import test_utils as utils + +CURRENT_PATH = os.path.dirname(os.path.realpath(__file__)) +with open(CURRENT_PATH + "/tasks_test_data.json") as data_file: + test_cases = json.load(data_file) + + +def api_create(self): + return self.tester.post('{0}{1}/{2}/{3}/'. + format(self.url, utils.SERVER_GROUP, + self.server_id, self.chain_id), + data=json.dumps(self.data), + content_type='html/json') + + +def api_delete(self, task_id=None): + if task_id is None: + task_id = self.task_id + return self.tester.delete('{0}{1}/{2}/{3}/{4}'. + format(self.url, utils.SERVER_GROUP, + self.server_id, self.chain_id, + task_id), + data=json.dumps(self.data), + content_type='html/json') + + +def api_put(self): + return self.tester.put('{0}{1}/{2}/{3}/{4}'. + format(self.url, utils.SERVER_GROUP, self.server_id, + self.chain_id, self.task_id), + data=json.dumps(self.data), + content_type='html/json') + + +def api_get(self, task_id=None): + if task_id is None: + task_id = self.task_id + return self.tester.get('{0}{1}/{2}/{3}/{4}'. + format(self.url, utils.SERVER_GROUP, + self.server_id, self.chain_id, + task_id), + content_type='html/json') + + +def api_get_msql(self, url_encode_data, task_id=None): + if task_id is None: + task_id = '/' + str(self.task_id) + return self.tester.get("{0}{1}/{2}/{3}{4}?{5}". + format(self.url, utils.SERVER_GROUP, + self.server_id, self.chain_id, + task_id, + urlencode(url_encode_data)), + data=json.dumps(self.data), + follow_redirects=True) + + +def api_get_sql(self): + return self.tester.get('{0}{1}/{2}/{3}/{4}'. + format(self.url.replace('/obj/', '/sql/'), + utils.SERVER_GROUP, + self.server_id, self.chain_id, + self.task_id), + content_type='html/json') + + +def api_get_stats(self): + return self.tester.get('{0}{1}/{2}/{3}/{4}'. + format(self.url.replace('/obj/', '/stats/'), + utils.SERVER_GROUP, + self.server_id, self.chain_id, + self.task_id), + content_type='html/json') diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros new file mode 100644 index 00000000000..bd53d50f724 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros @@ -0,0 +1,72 @@ +{% macro INSERT(has_connstr, chain_id, data, conn) -%} +INSERT INTO timetable.task( + chain_id, task_name, task_order, command, kind, database_connection + {% if data.ignore_error is defined %}, ignore_error{% endif %} +) VALUES ( + {{ chain_id|qtLiteral(conn) }}::integer, + {{ data.task_name|qtLiteral(conn) }}::text, + {{ data.task_order|qtLiteral(conn) }}::integer, + {{ data.command|qtLiteral(conn) }}::text, + {{ data.kind|qtLiteral(conn) }}::timetable.command_kind + {% if has_connstr and data.database_connection %}, + {{ data.database_connection|qtLiteral(conn) }}::text{% else %}NULL{% endif %} + {% if data.ignore_error is defined %}, + {% if data.ignore_error %}true{% else %}false{% endif %}{% endif %} +) RETURNING task_id; +{%- endmacro %} +{% macro UPDATE(has_connstr, chain_id, task_id, data, conn) -%} +{% set has_task_fields = 'task_name' in data or 'task_order' in data or 'command' in data or 'kind' in data or 'ignore_error' in data or 'database_connection' in data %} +{% if has_task_fields %} +{% set comma = joiner(', ') %} +UPDATE timetable.task +SET +{% if 'task_name' in data %}{{ comma() }} + task_name = {{ data.task_name|qtLiteral(conn) }}::text{% endif %}{% if 'task_order' in data %}{{ comma() }} + task_order = {{ data.task_order|qtLiteral(conn) }}::integer{% endif %}{% if 'command' in data %}{{ comma() }} + command = {{ data.command|qtLiteral(conn) }}::text{% endif %}{% if 'kind' in data %}{{ comma() }} + kind = {{ data.kind|qtLiteral(conn) }}::timetable.command_kind{% endif %}{% if 'ignore_error' in data %}{{ comma() }} + ignore_error = {% if data.ignore_error %}true{% else %}false{% endif %}{% endif %}{% if 'database_connection' in data %}{{ comma() }} + database_connection = {{ data.database_connection|qtLiteral(conn) }}::text{% endif %} + WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer AND chain_id = {{ chain_id|qtLiteral(conn) }}::integer; +{% endif %} +{% if 'parameters' in data %} +DELETE FROM timetable.parameter WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer; +{% for param in data.parameters %} +INSERT INTO timetable.parameter(task_id, order_id, value) +VALUES ({{ task_id|qtLiteral(conn) }}::integer, {{ param.order_id|qtLiteral(conn) }}::integer, {% if param._is_json %}{{ param.value|qtLiteral(conn) }}::jsonb{% else %}to_jsonb({{ param.value|qtLiteral(conn) }}::text){% endif %}); +{% endfor %} +{% endif %} +{%- endmacro %} +{% macro DELETE(chain_id, task_id, conn) -%} +DELETE FROM timetable.parameter WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer; +DELETE FROM timetable.task +WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer AND chain_id = {{ chain_id|qtLiteral(conn) }}::integer; +{%- endmacro %} +{% macro PROPERTIES(has_connstr, chain_id, task_id, conn) -%} +SELECT + t.task_id, t.chain_id, t.task_name, t.task_order, + t.kind::text AS kind, + t.command, t.database_connection, t.ignore_error, + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'order_id', p.order_id, + 'value', p.value #>> '{}' + ) + ORDER BY p.order_id + ) + FROM timetable.parameter p + WHERE p.task_id = t.task_id + ), + '[]'::jsonb + ) AS parameters +FROM + timetable.task t +WHERE +{% if task_id %} + t.task_id = {{ task_id|qtLiteral(conn) }}::integer AND +{% endif %} + t.chain_id = {{ chain_id|qtLiteral(conn) }}::integer +ORDER BY t.task_name; +{%- endmacro %} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/css/pgt_chain.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/css/pgt_chain.css new file mode 100644 index 00000000000..1dd88450b4e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/css/pgt_chain.css @@ -0,0 +1,26 @@ +.icon-pgt_chain { + background-image: url('{{ url_for('NODE-pgt_chain.static', filename='img/pgt_chain.svg') }}') !important; + background-repeat: no-repeat; + background-size: 20px !important; + align-content: center; + vertical-align: middle; + height: 1.3em; +} + +.icon-pgt_chain-disabled { + background-image: url('{{ url_for('NODE-pgt_chain.static', filename='img/pgt_chain-disabled.svg') }}') !important; + background-repeat: no-repeat; + background-size: 20px !important; + align-content: center; + vertical-align: middle; + height: 1.3em; +} + +.icon-coll-pgt_chain { + background-image: url('{{ url_for('NODE-pgt_chain.static', filename='img/coll-pgt_chain.svg') }}') !important; + background-repeat: no-repeat; + background-size: 20px !important; + align-content: center; + vertical-align: middle; + height: 1.3em; +} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/create.sql new file mode 100644 index 00000000000..30998bd85ff --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/create.sql @@ -0,0 +1,43 @@ +DO $$ +DECLARE + cid integer; + tid integer; +BEGIN +INSERT INTO timetable.chain( + chain_name, run_at, client_name, live, max_instances, timeout, self_destruct, exclusive_execution, on_error +) VALUES ( + {{ data.chain_name|qtLiteral(conn) }}::text, + {% if data.run_at and data.run_at|length > 0 %}{{ data.run_at|qtLiteral(conn) }}::text{% else %}NULL{% endif %}, + {{ data.client_name|qtLiteral(conn) }}::text, + {% if data.live %}true{% else %}false{% endif %}, + {% if data.max_instances is not none %}{{ data.max_instances|qtLiteral(conn) }}::integer{% else %}NULL{% endif %}, + {% if data.timeout is not none %}{{ data.timeout|qtLiteral(conn) }}::integer{% else %}0{% endif %}, + {% if data.self_destruct %}true{% else %}false{% endif %}, + {% if data.exclusive_execution %}true{% else %}false{% endif %}, + {% if data.on_error and data.on_error|length > 0 %}{{ data.on_error|qtLiteral(conn) }}::text{% else %}NULL{% endif %} +) RETURNING chain_id INTO cid; +{% if 'ctasks' in data and data.ctasks|length > 0 %} + +{% for task in data.ctasks %} +INSERT INTO timetable.task( + chain_id, task_name, task_order, command, kind + {% if 'ignore_error' in task %}, ignore_error{% endif %} + {% if 'database_connection' in task and task.database_connection %}, database_connection{% endif %} +) VALUES ( + cid, {{ task.task_name|qtLiteral(conn) }}::text, {{ task.task_order|qtLiteral(conn) }}::integer, {{ task.command|qtLiteral(conn) }}::text, {{ task.kind|qtLiteral(conn) }}::timetable.command_kind + {% if 'ignore_error' in task %}, {% if task.ignore_error %}true{% else %}false{% endif %}{% endif %} + {% if 'database_connection' in task and task.database_connection %}, {{ task.database_connection|qtLiteral(conn) }}::text{% endif %} +) RETURNING task_id INTO tid; +{% if 'parameters' in task and task.parameters|length > 0 %} +{% for param in task.parameters %} +INSERT INTO timetable.parameter(task_id, order_id, value) +VALUES (tid, {{ param.order_id|qtLiteral(conn) }}::integer, {% if param._is_json %}{{ param.value|qtLiteral(conn) }}::jsonb{% else %}to_jsonb({{ param.value|qtLiteral(conn) }}::text){% endif %}); +{% endfor %} +{% endif %} +{% endfor %}{% endif %} + +END +$$; +{% if fetch_id %} +SELECT chain_id FROM timetable.chain WHERE chain_name = {{ data.chain_name|qtLiteral(conn) }}::text; +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/delete.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/delete.sql similarity index 100% rename from web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/delete.sql rename to web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/delete.sql diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/nodes.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/nodes.sql new file mode 100644 index 00000000000..37c814ffb98 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/nodes.sql @@ -0,0 +1,8 @@ +SELECT + chain_id, chain_name, run_at, max_instances, timeout, live, self_destruct, exclusive_execution, client_name, on_error +FROM + timetable.chain +{% if chain_id %} +WHERE chain_id = {{ chain_id|qtLiteral(conn) }}::integer +{% endif %} +ORDER BY chain_name; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/properties.sql new file mode 100644 index 00000000000..792f54e54b9 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/properties.sql @@ -0,0 +1,29 @@ +SELECT + j.chain_id, j.chain_name, j.live, + j.max_instances, j.timeout, j.self_destruct, + j.exclusive_execution, j.on_error, + j.client_name, + CASE WHEN el.returncode = 0 THEN 'success' ELSE 'failure (' || returncode::text || ')' END AS status, + el.last_run, el.finished, + CASE + WHEN (el.finished - el.last_run) > INTERVAL '1 day' THEN + to_char((el.finished - el.last_run), 'DD" days "HH24"h" MI"m" SS"s"') + ELSE + to_char((el.finished - el.last_run), 'HH24"h" MI"m" SS"s"') + END AS duration, + j.run_at, + el.last_run, + CASE WHEN j.live THEN timetable.next_run(j.run_at) ELSE NULL END AS next_run, + COALESCE(ac.client_name, 'Not running') AS currently_running_on +FROM + timetable.chain j + LEFT OUTER JOIN ( + SELECT DISTINCT ON (l.chain_id) returncode, chain_id, last_run, finished + FROM timetable.execution_log AS l + ORDER BY chain_id, txid DESC + ) el ON el.chain_id = j.chain_id + LEFT OUTER JOIN timetable.active_chain AS ac ON j.chain_id = ac.chain_id +{% if chain_id %} +WHERE j.chain_id = {{ chain_id|qtLiteral(conn) }}::integer +{% endif %} +ORDER BY j.chain_name; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql new file mode 100644 index 00000000000..018aa3f2d87 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql @@ -0,0 +1,8 @@ +SELECT timetable.notify_chain_start( + {{ chain_id|qtLiteral(conn) }}::bigint, + ( + SELECT client_name + FROM timetable.chain + WHERE chain_id = {{ chain_id|qtLiteral(conn) }}::integer + ) +) AS result; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/stats.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/stats.sql new file mode 100644 index 00000000000..8bbba6884e6 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/stats.sql @@ -0,0 +1,18 @@ +SELECT + el.txid AS {{ conn|qtIdent(_('Run')) }}, + CASE WHEN el.returncode = 0 THEN 'success' ELSE 'failure (' || returncode::text || ')' END AS {{ conn|qtIdent(_('Status')) }}, + el.last_run AS {{ conn|qtIdent(_('Start time')) }}, + el.finished AS {{ conn|qtIdent(_('End time')) }}, + CASE + WHEN (el.finished - el.last_run) > INTERVAL '1 day' THEN + to_char((el.finished - el.last_run), 'DD" days "HH24"h" MI"m" SS"s"') + ELSE + to_char((el.finished - el.last_run), 'HH24"h" MI"m" SS"s"') + END AS {{ conn|qtIdent(_('Duration')) }}, + t.task_name AS {{ conn|qtIdent(_('Task')) }} +FROM + timetable.execution_log AS el INNER JOIN timetable.task AS t On (el.chain_id = t.chain_id AND el.task_id = t.task_id) +WHERE + el.chain_id = {{ chain_id|qtLiteral(conn) }}::integer +ORDER BY last_run DESC, txid DESC +LIMIT {{ rows_threshold }}; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql new file mode 100644 index 00000000000..b78af6d437e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql @@ -0,0 +1,2 @@ +{% import 'macros/pgt_chaintask.macros' as TASK %} +{{ TASK.PROPERTIES(has_connstr, chain_id, task_id, conn) }} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql new file mode 100644 index 00000000000..47aae734ee9 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql @@ -0,0 +1,13 @@ +{% if 'chain_name' in data or 'live' in data or 'max_instances' in data or 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %} +UPDATE timetable.chain +SET {% if 'chain_name' in data %}chain_name = {{ data.chain_name|qtLiteral(conn) }}::text{% if 'live' in data or 'max_instances' in data or 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} +{% if 'live' in data %}live = {% if data.live %}true{% else %}false{% endif %}{% if 'max_instances' in data or 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} +{% if 'max_instances' in data %}max_instances = {% if data.max_instances is not none %}{{ data.max_instances|qtLiteral(conn) }}::integer{% else %}NULL{% endif %}{% if 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} +{% if 'timeout' in data %}timeout = {% if data.timeout is not none %}{{ data.timeout|qtLiteral(conn) }}::integer{% else %}0{% endif %}{% if 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} +{% if 'self_destruct' in data %}self_destruct = {% if data.self_destruct %}true{% else %}false{% endif %}{% if 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} +{% if 'exclusive_execution' in data %}exclusive_execution = {% if data.exclusive_execution %}true{% else %}false{% endif %}{% if 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} +{% if 'client_name' in data %}client_name = {% if data.client_name and data.client_name|length > 0 %}{{ data.client_name|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% if 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} +{% if 'on_error' in data %}on_error = {% if data.on_error and data.on_error|length > 0 %}{{ data.on_error|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% if 'run_at' in data %}, {% endif %}{% endif %} +{% if 'run_at' in data %}run_at = {% if data.run_at and data.run_at|length > 0 %}{{ data.run_at|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% endif %} +WHERE chain_id = {{ chain_id|qtLiteral(conn) }}::integer; +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/css/pgt_chaintask.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/css/pgt_chaintask.css new file mode 100644 index 00000000000..339ae85c290 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/css/pgt_chaintask.css @@ -0,0 +1,26 @@ +.icon-pgt_chaintask { + background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/pgt_chaintask.svg') }}') !important; + background-repeat: no-repeat; + background-size: 20px !important; + align-content: center; + vertical-align: middle; + height: 1.3em; +} + +.icon-pgt_chaintask-disabled { + background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/pgt_chaintask-disabled.svg') }}') !important; + background-repeat: no-repeat; + background-size: 20px !important; + align-content: center; + vertical-align: middle; + height: 1.3em; +} + +.icon-coll-pgt_chaintask { + background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/coll-pgt_chaintask.svg') }}') !important; + background-repeat: no-repeat; + background-size: 20px !important; + align-content: center; + vertical-align: middle; + height: 1.3em; +} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql new file mode 100644 index 00000000000..4ff05d07171 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql @@ -0,0 +1,21 @@ +{% import 'macros/pgt_chaintask.macros' as TASK %} +WITH tid AS ( + {{ TASK.INSERT(has_connstr, chain_id, data, conn) }} +) +{% if 'parameters' in data and data.parameters|length > 0 %} +, ins AS ( + INSERT INTO timetable.parameter(task_id, order_id, value) + SELECT tid.task_id, p.order_id, p.val + FROM tid, + (VALUES + {% for param in data.parameters %} + ({{ param.order_id|qtLiteral(conn) }}::integer, {% if param._is_json %}{{ param.value|qtLiteral(conn) }}::jsonb{% else %}to_jsonb({{ param.value|qtLiteral(conn) }}::text){% endif %}) + {% if not loop.last %},{% endif %} + {% endfor %} + ) AS p(order_id, val) + RETURNING task_id +) +SELECT task_id FROM ins LIMIT 1 +{% else %} +SELECT task_id FROM tid; +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/delete.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/delete.sql new file mode 100644 index 00000000000..d2da5b04fa9 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/delete.sql @@ -0,0 +1,2 @@ +{% import 'macros/pgt_chaintask.macros' as TASK %} +{{ TASK.DELETE(chain_id, task_id, conn) }} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql new file mode 100644 index 00000000000..a5414816331 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql @@ -0,0 +1,11 @@ +SELECT + task_id, chain_id, task_name, task_order, + CASE WHEN kind::text = 'SQL' THEN true ELSE false END AS kind +FROM + timetable.task +WHERE +{% if task_id %} + task_id = {{ task_id|qtLiteral(conn) }}::integer AND +{% endif %} + chain_id = {{ chain_id|qtLiteral(conn) }}::integer +ORDER BY task_order; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql new file mode 100644 index 00000000000..b78af6d437e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql @@ -0,0 +1,2 @@ +{% import 'macros/pgt_chaintask.macros' as TASK %} +{{ TASK.PROPERTIES(has_connstr, chain_id, task_id, conn) }} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/stats.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/stats.sql new file mode 100644 index 00000000000..b06eca79082 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/stats.sql @@ -0,0 +1,19 @@ +SELECT + txid AS {{ conn|qtIdent(_('Run')) }}, + CASE WHEN returncode = 0 THEN 'success' ELSE 'failure (' || returncode::text || ')' END AS {{ conn|qtIdent(_('Status')) }}, + last_run AS {{ conn|qtIdent(_('Start time')) }}, + CASE + WHEN (el.finished - el.last_run) > INTERVAL '1 day' THEN + to_char((el.finished - el.last_run), 'DD" days "HH24"h" MI"m" SS"s"') + ELSE + to_char((el.finished - el.last_run), 'HH24"h" MI"m" SS"s"') + END AS {{ conn|qtIdent(_('Duration')) }}, + el.finished AS {{ conn|qtIdent(_('End time')) }}, + left(output,500) AS {{ conn|qtIdent(_('Output')) }} +FROM + timetable.execution_log AS el +WHERE + chain_id = {{ chain_id|qtLiteral(conn) }}::integer + AND task_id = {{ task_id|qtLiteral(conn) }}::integer +ORDER BY last_run DESC NULLS FIRST, txid DESC +LIMIT {{ rows_threshold }}; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql new file mode 100644 index 00000000000..adbc82fc3e4 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql @@ -0,0 +1,2 @@ +{% import 'macros/pgt_chaintask.macros' as TASK %} +{{ TASK.UPDATE(has_connstr, chain_id, task_id, data, conn) }} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/__init__.py new file mode 100644 index 00000000000..cf4bf367cac --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/__init__.py @@ -0,0 +1,15 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +from pgadmin.utils.route import BaseTestGenerator + + +class PgTimetableCreateTestCase(BaseTestGenerator): + def runTest(self): + return diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json new file mode 100644 index 00000000000..97f797aabd9 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json @@ -0,0 +1,447 @@ +{ + "pgt_chain_create": [ + { + "name": "Create chain: With valid data.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "chain_name": "test_chain_add%s", + "live": true, + "max_instances": null, + "timeout": 0, + "self_destruct": false, + "exclusive_execution": false, + "client_name": "", + "on_error": null, + "run_at": "" + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Create chain: With invalid data (missing chain_name).", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": { + "live": true + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 410, + "error_msg": "Could not find the required parameter (chain_name).", + "test_result_data": {} + } + }, + { + "name": "Create chain: With valid data while server down.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": { + "chain_name": "test_chain_add%s", + "live": true + }, + "mocking_required": true, + "mock_data": { + "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_scalar", + "return_value": "[(False,'Mocked Internal Server Error')]" + }, + "expected_data": { + "status_code": 500, + "error_msg": "Mocked Internal Server Error", + "test_result_data": {} + } + }, + { + "name": "Create chain: With tasks and parameters.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "chain_name": "test_chain_add%s", + "live": true, + "ctasks": [ + { + "task_name": "test_task_1", + "task_order": 10, + "kind": "SQL", + "command": "SELECT 1;", + "ignore_error": false, + "parameters": [ + { + "order_id": 1, + "value": "param_value" + } + ] + } + ] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + } + ], + "pgt_chain_get": [ + { + "name": "Get chain: With existing chain.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": false + }, + { + "name": "Get chains: With existing chains.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": true + }, + { + "name": "Get chain: With non-existing chain.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": { + "chain_id": 99999 + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 410, + "error_msg": "Could not find the pgTimeTable chain on the server.", + "test_result_data": {} + }, + "is_list": false + }, + { + "name": "Get chain: With existing chain while server down.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": {}, + "mocking_required": true, + "mock_data": { + "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict", + "return_value": "(False,'Mocked Internal Server Error')" + }, + "expected_data": { + "status_code": 500, + "error_msg": "Mocked Internal Server Error", + "test_result_data": {} + }, + "is_list": false + } + ], + "pgt_chain_put": [ + { + "name": "Update chain: With description.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "chain_name": "updated_chain_name" + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Update chain: Disable chain.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "live": false + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Update chain: With existing chain while server down.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": { + "chain_name": "updated_chain_name" + }, + "mocking_required": true, + "mock_data": { + "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_void", + "return_value": "(False,'Mocked Internal Server Error')" + }, + "expected_data": { + "status_code": 500, + "error_msg": "Mocked Internal Server Error", + "test_result_data": {} + } + } + ], + "pgt_chain_delete": [ + { + "name": "Delete chain: With existing chain.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": false + }, + { + "name": "Delete chains: With existing chains.", + "url": "/browser/pgt_chain/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": true + } + ], + "pgt_chain_sql": [ + { + "name": "Get chain sql: With existing chain.", + "url": "/browser/pgt_chain/sql/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Get chain sql: With non-existing chain.", + "url": "/browser/pgt_chain/sql/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": { + "chain_id": 99999 + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 410, + "error_msg": "Could not find the object on the server.", + "test_result_data": {} + } + }, + { + "name": "Get chain sql: With existing chain while server down.", + "url": "/browser/pgt_chain/sql/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": {}, + "mocking_required": true, + "mock_data": { + "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict", + "return_value": "(False,'Mocked Internal Server Error')" + }, + "expected_data": { + "status_code": 500, + "error_msg": "Mocked Internal Server Error", + "test_result_data": {} + } + } + ], + "pgt_chain_get_nodes": [ + { + "name": "Get chain nodes: With existing chain.", + "url": "/browser/pgt_chain/nodes/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": false + }, + { + "name": "Get chains nodes: With existing chains.", + "url": "/browser/pgt_chain/nodes/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + }, + "is_list": true + }, + { + "name": "Get chain nodes: With non-existing chain.", + "url": "/browser/pgt_chain/nodes/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": { + "chain_id": 99999 + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 410, + "error_msg": "Could not find the pgTimeTable chain on the server.", + "test_result_data": {} + }, + "is_list": false + }, + { + "name": "Get chain nodes: With existing chain while server down.", + "url": "/browser/pgt_chain/nodes/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": {}, + "mocking_required": true, + "mock_data": { + "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict", + "return_value": "(False,'Mocked Internal Server Error')" + }, + "expected_data": { + "status_code": 500, + "error_msg": "Mocked Internal Server Error", + "test_result_data": {} + }, + "is_list": false + } + ], + "pgt_chain_msql": [ + { + "name": "Get chain msql: With existing chain and updated name.", + "url": "/browser/pgt_chain/msql/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "chain_name": "updated_chain_name" + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Get chain msql: With existing chain disable.", + "url": "/browser/pgt_chain/msql/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "live": false + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + } + ], + "pgt_chain_get_statistics": [ + { + "name": "Get chain stats: With existing chain.", + "url": "/browser/pgt_chain/stats/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Get chain stats: With existing chain while server down.", + "url": "/browser/pgt_chain/stats/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": {}, + "mocking_required": true, + "mock_data": { + "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict", + "return_value": "(False,'Mocked Internal Server Error')" + }, + "expected_data": { + "status_code": 500, + "error_msg": "Mocked Internal Server Error", + "test_result_data": {} + } + } + ], + "pgt_chain_run_now": [ + { + "name": "Run chain now: With existing chain.", + "url": "/browser/pgt_chain/run_now/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + } + ] +} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_add.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_add.py new file mode 100644 index 00000000000..8445964c75e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_add.py @@ -0,0 +1,68 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## +from unittest.mock import patch + +import json +import uuid +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from . import utils as pgt_utils + + +class PgtChainAddTestCase(BaseTestGenerator): + """This class will test the add pgTimetable chain API""" + scenarios = utils.generate_scenarios("pgt_chain_create", + pgt_utils.test_cases) + + def setUp(self): + super().setUp() + self.data = self.test_data + + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + def runTest(self): + """This function will add pgTimetable chain""" + self.pgt_chain = "test_chain_add%s" % str(uuid.uuid4())[1:8] + + if "chain_name" in self.data: + self.data["chain_name"] = self.pgt_chain + + if self.is_positive_test: + response = pgt_utils.api_create(self) + + utils.assert_status_code(self, response) + + response_data = json.loads(response.data) + self.chain_id = response_data['node']['_id'] + is_present = pgt_utils.verify_pgtimetable_chain(self) + self.assertTrue(is_present, + "pgTimetable chain was not created successfully") + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=eval(self.mock_data["return_value"])): + response = pgt_utils.api_create(self) + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + else: + response = pgt_utils.api_create(self) + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + """Clean up code""" + if self.is_positive_test: + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py new file mode 100644 index 00000000000..8bedfb5769e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py @@ -0,0 +1,57 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from . import utils as pgt_utils + + +class PgtChainDeleteTestCase(BaseTestGenerator): + """This class will test the delete pgTimetable chain API""" + scenarios = utils.generate_scenarios("pgt_chain_delete", + pgt_utils.test_cases) + + def setUp(self): + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_delete%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + if self.is_list: + name2 = "test_chain2_delete%s" % str(uuid.uuid4())[1:8] + self.chain_id2 = pgt_utils.create_pgtimetable_chain(self, name2) + + def runTest(self): + """This function will delete pgTimetable chain""" + if self.is_positive_test: + if self.is_list: + self.data['ids'] = [self.chain_id, self.chain_id2] + response = pgt_utils.api_delete(self, '') + else: + response = pgt_utils.api_delete(self) + + utils.assert_status_code(self, response) + + is_present = pgt_utils.verify_pgtimetable_chain(self) + self.assertFalse( + is_present, "pgTimetable chain was not deleted successfully") + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) + if self.is_list: + pgt_utils.delete_pgtimetable_chain(self, self.chain_id2) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get.py new file mode 100644 index 00000000000..01e833256a7 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get.py @@ -0,0 +1,69 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from unittest.mock import patch + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from . import utils as pgt_utils + + +class PgtChainGetTestCase(BaseTestGenerator): + """This class will test the get pgTimetable chain API""" + scenarios = utils.generate_scenarios("pgt_chain_get", + pgt_utils.test_cases) + + def setUp(self): + super().setUp() + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_get%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + if self.is_list: + name_2 = "test_chain_get%s" % str(uuid.uuid4())[1:8] + self.chain_id_2 = pgt_utils.create_pgtimetable_chain(self, name_2) + + def runTest(self): + """This function will get pgTimetable chain""" + + if self.is_positive_test: + if self.is_list: + response = pgt_utils.api_get(self, '') + else: + response = pgt_utils.api_get(self) + + utils.assert_status_code(self, response) + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])]): + response = pgt_utils.api_get(self) + elif 'chain_id' in self.data: + existing_chain_id = self.chain_id + self.chain_id = self.data["chain_id"] + response = pgt_utils.api_get(self) + self.chain_id = existing_chain_id + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) + if self.is_list: + pgt_utils.delete_pgtimetable_chain(self, self.chain_id_2) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py new file mode 100644 index 00000000000..f0a8b6041d3 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py @@ -0,0 +1,48 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## +from unittest.mock import patch + +import json +import uuid +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from . import utils as pgt_utils + + +class PgtChainGetMsqlTestCase(BaseTestGenerator): + """This class will test the msql for pgTimetable chain API""" + scenarios = utils.generate_scenarios("pgt_chain_msql", + pgt_utils.test_cases) + + def setUp(self): + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_msql%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + def runTest(self): + """This function will get msql for pgTimetable chain""" + + if self.is_positive_test: + url_encode_data = self.data + + response = pgt_utils.api_get_msql(self, url_encode_data) + + utils.assert_status_code(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py new file mode 100644 index 00000000000..726d2fb3abc --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py @@ -0,0 +1,68 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from unittest.mock import patch + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from . import utils as pgt_utils + + +class PgtChainGetNodesTestCase(BaseTestGenerator): + """This class will test the get pgTimetable chain nodes API""" + scenarios = utils.generate_scenarios("pgt_chain_get_nodes", + pgt_utils.test_cases) + + def setUp(self): + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_get_nodes%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + if self.is_list: + name_2 = "test_chain_get_nodes%s" % str(uuid.uuid4())[1:8] + self.chain_id_2 = pgt_utils.create_pgtimetable_chain(self, name_2) + + def runTest(self): + """This function will get pgTimetable chain nodes""" + + if self.is_positive_test: + if self.is_list: + response = pgt_utils.api_get(self, '') + else: + response = pgt_utils.api_get(self) + + utils.assert_status_code(self, response) + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])]): + response = pgt_utils.api_get(self) + elif 'chain_id' in self.data: + existing_chain_id = self.chain_id + self.chain_id = self.data["chain_id"] + response = pgt_utils.api_get(self) + self.chain_id = existing_chain_id + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) + if self.is_list: + pgt_utils.delete_pgtimetable_chain(self, self.chain_id_2) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py new file mode 100644 index 00000000000..4ccbff0aed9 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py @@ -0,0 +1,55 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from unittest.mock import patch + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from . import utils as pgt_utils + + +class PgtChainGetStatsTestCase(BaseTestGenerator): + """This class will test the get pgTimetable chain stats API""" + scenarios = utils.generate_scenarios("pgt_chain_get_statistics", + pgt_utils.test_cases) + + def setUp(self): + super().setUp() + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_get_stats%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + def runTest(self): + """This function will get pgTimetable chain stats""" + + if self.is_positive_test: + response = pgt_utils.api_get_stats(self) + + utils.assert_status_code(self, response) + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])]): + response = pgt_utils.api_get_stats(self) + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py new file mode 100644 index 00000000000..40d052d9093 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py @@ -0,0 +1,55 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## +from unittest.mock import patch + +import json +import uuid +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from . import utils as pgt_utils + + +class PgtChainPutTestCase(BaseTestGenerator): + """This class will test the put pgTimetable chain API""" + scenarios = utils.generate_scenarios("pgt_chain_put", + pgt_utils.test_cases) + + def setUp(self): + super().setUp() + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_put%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + def runTest(self): + """This function will update pgTimetable chain""" + + if self.is_positive_test: + response = pgt_utils.api_put(self) + + utils.assert_status_code(self, response) + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])]): + response = pgt_utils.api_put(self) + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py new file mode 100644 index 00000000000..731abfbe160 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py @@ -0,0 +1,59 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from unittest.mock import patch + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from . import utils as pgt_utils + + +class PgtChainSqlTestCase(BaseTestGenerator): + """This class will test the get pgTimetable chain sql API""" + scenarios = utils.generate_scenarios("pgt_chain_sql", + pgt_utils.test_cases) + + def setUp(self): + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_sql%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + def runTest(self): + """This function will get pgTimetable chain sql""" + + if self.is_positive_test: + response = pgt_utils.api_get_sql(self) + + utils.assert_status_code(self, response) + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])]): + response = pgt_utils.api_get_sql(self) + elif 'chain_id' in self.data: + existing_chain_id = self.chain_id + self.chain_id = self.data["chain_id"] + response = pgt_utils.api_get_sql(self) + self.chain_id = existing_chain_id + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py new file mode 100644 index 00000000000..a6645520dd0 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py @@ -0,0 +1,320 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import sys +import traceback +import os +import json +from urllib.parse import urlencode + +from regression.python_test_utils import test_utils as utils +from regression import parent_node_dict +from pgadmin.utils import server_utils + +CURRENT_PATH = os.path.dirname(os.path.realpath(__file__)) +with open(CURRENT_PATH + "/pgt_timetable_test_data.json") as data_file: + test_cases = json.load(data_file) + + +def api_create(self): + return self.tester.post('{0}{1}/{2}/'. + format(self.url, str(utils.SERVER_GROUP), + str(self.server_id)), + data=json.dumps(self.data), + content_type='html/json') + + +def api_get(self, chain_id=None): + if chain_id is None: + chain_id = self.chain_id + return self.tester.get('{0}{1}/{2}/{3}'. + format(self.url, utils.SERVER_GROUP, + self.server_id, chain_id), + content_type='html/json') + + +def api_put(self): + return self.tester.put('{0}{1}/{2}/{3}'. + format(self.url, utils.SERVER_GROUP, + self.server_id, self.chain_id), + data=json.dumps(self.data), + follow_redirects=True, + content_type='html/json') + + +def api_delete(self, chain_id=None): + if chain_id is None: + chain_id = self.chain_id + return self.tester.delete('{0}{1}/{2}/{3}'. + format(self.url, utils.SERVER_GROUP, + self.server_id, chain_id), + data=json.dumps(self.data), + content_type='html/json') + + +def api_get_msql(self, url_encode_data): + return self.tester.get("{0}{1}/{2}/{3}?{4}". + format(self.url, utils.SERVER_GROUP, + self.server_id, self.chain_id, + urlencode(url_encode_data)), + follow_redirects=True) + + +def api_get_sql(self): + return self.tester.get('{0}{1}/{2}/{3}'. + format(self.url.replace('/obj/', '/sql/'), + utils.SERVER_GROUP, + self.server_id, self.chain_id), + content_type='html/json') + + +def api_get_stats(self): + return self.tester.get('{0}{1}/{2}/{3}'. + format(self.url.replace('/obj/', '/stats/'), + utils.SERVER_GROUP, + self.server_id, self.chain_id), + content_type='html/json') + + +def api_run_now(self): + return self.tester.put('{0}{1}/{2}/{3}'. + format(self.url.replace('/obj/', '/run_now/'), + utils.SERVER_GROUP, + self.server_id, self.chain_id), + data=json.dumps(self.data), + content_type='html/json') + + +def is_valid_server_to_run_pgtimetable(self): + self.server_id = parent_node_dict["server"][-1]["server_id"] + server_con = server_utils.connect_server(self, self.server_id) + if not server_con["info"] == "Server connected.": + raise Exception("Could not connect to server to add pgTimetable chain.") + return True, None + + +def is_pgtimetable_installed_on_server(self): + try: + connection = utils.get_db_connection( + self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode'] + ) + pg_cursor = connection.cursor() + + SQL = """ + SELECT + has_table_privilege( + 'timetable.chain', 'INSERT, SELECT, UPDATE' + ) has_priviledge + WHERE EXISTS( + SELECT has_schema_privilege('timetable', 'USAGE') + WHERE EXISTS( + SELECT cl.oid FROM pg_catalog.pg_class cl + LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid=relnamespace + WHERE relname='chain' AND nspname='timetable' + ) + ) + """ + pg_cursor.execute(SQL) + result = pg_cursor.fetchone() + if result is None: + connection.close() + message = "Make sure pgTimetable is installed properly." + return False, message + + SQL = """ + SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE + table_schema='timetable' AND table_name='task' AND + column_name='database_connection' + ) has_connstr + """ + pg_cursor.execute(SQL) + result = pg_cursor.fetchone() + if result is None: + connection.close() + message = "Make sure pgTimetable is installed properly." + return False, message + + connection.close() + return True, None + except Exception: + traceback.print_exc(file=sys.stderr) + + +def create_pgtimetable_chain(self, name): + try: + connection = utils.get_db_connection( + self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode'] + ) + old_isolation_level = connection.isolation_level + utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + pg_cursor.execute( + """ + INSERT INTO timetable.chain( + chain_name, live + ) VALUES ( + '{0}'::text, true + ) RETURNING chain_id; + """.format(name) + ) + chain_id = pg_cursor.fetchone() + utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + connection.close() + return chain_id[0] + except Exception: + traceback.print_exc(file=sys.stderr) + + +def delete_pgtimetable_chain(self, chain_id=None): + if chain_id is None: + chain_id = self.chain_id + try: + connection = utils.get_db_connection( + self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode'] + ) + old_isolation_level = connection.isolation_level + utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + pg_cursor.execute( + "DELETE FROM timetable.task " + "WHERE chain_id = '%s'::integer;" % chain_id + ) + pg_cursor.execute( + "DELETE FROM timetable.chain " + "WHERE chain_id = '%s'::integer;" % chain_id + ) + utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + connection.close() + except Exception: + traceback.print_exc(file=sys.stderr) + + +def verify_pgtimetable_chain(self): + try: + connection = utils.get_db_connection( + self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode'] + ) + pg_cursor = connection.cursor() + pg_cursor.execute( + "SELECT COUNT(*) FROM timetable.chain " + "WHERE chain_id = '%s'::integer;" % self.chain_id + ) + result = pg_cursor.fetchone() + count = result[0] + connection.close() + return count is not None and int(count) != 0 + except Exception: + traceback.print_exc(file=sys.stderr) + + +def create_pgtimetable_task(self, task_name, chain_id): + try: + connection = utils.get_db_connection( + self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode'] + ) + old_isolation_level = connection.isolation_level + utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + query = """ + INSERT INTO timetable.task( + chain_id, task_name, task_order, command, kind + ) VALUES ( + {0}::integer, '{1}'::text, 10, 'SELECT 1', 'SQL' + ) RETURNING task_id; + """.format(chain_id, task_name) + pg_cursor.execute(query) + task_id = pg_cursor.fetchone() + utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + connection.close() + return task_id[0] + except Exception: + traceback.print_exc(file=sys.stderr) + + +def delete_pgtimetable_task(self, task_id=None): + if task_id is None: + task_id = self.task_id + try: + connection = utils.get_db_connection( + self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode'] + ) + old_isolation_level = connection.isolation_level + utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + pg_cursor.execute( + "DELETE FROM timetable.parameter " + "WHERE task_id = '%s'::integer;" % task_id + ) + pg_cursor.execute( + "DELETE FROM timetable.task " + "WHERE task_id = '%s'::integer;" % task_id + ) + utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + connection.close() + except Exception: + traceback.print_exc(file=sys.stderr) + + +def verify_pgtimetable_task(self): + try: + connection = utils.get_db_connection( + self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode'] + ) + pg_cursor = connection.cursor() + pg_cursor.execute( + "SELECT COUNT(*) FROM timetable.task " + "WHERE task_id = '%s'::integer;" % self.task_id + ) + result = pg_cursor.fetchone() + count = result[0] + connection.close() + return count is not None and int(count) != 0 + except Exception: + traceback.print_exc(file=sys.stderr) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py new file mode 100644 index 00000000000..228d46b3203 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py @@ -0,0 +1,11 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""pgtimetable helper utilities""" +from flask import render_template diff --git a/web/webpack.config.js b/web/webpack.config.js index 6e5c6877980..510a204b23d 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -231,6 +231,7 @@ module.exports = [{ 'pure|pgadmin.node.replica_node', 'pure|pgadmin.node.pgd_replication_groups', 'pure|pgadmin.node.pgd_replication_servers', + 'pure|pgadmin.node.pgt_chain', ], }, }, @@ -252,6 +253,7 @@ module.exports = [{ 'pure|pgadmin.tools.import_export_servers', 'pure|pgadmin.tools.debugger', 'pure|pgadmin.node.pga_job', + 'pure|pgadmin.node.pgt_chain', 'pure|pgadmin.tools.schema_diff', 'pure|pgadmin.tools.file_manager', 'pure|pgadmin.tools.search_objects', diff --git a/web/webpack.shim.js b/web/webpack.shim.js index c370b31af72..7e3ce7c1cf9 100644 --- a/web/webpack.shim.js +++ b/web/webpack.shim.js @@ -118,6 +118,8 @@ let webpackShimConfig = { 'pgadmin.node.pga_job': path.join(__dirname, './pgadmin/browser/server_groups/servers/pgagent/static/js/pga_job'), 'pgadmin.node.pga_jobstep': path.join(__dirname, './pgadmin/browser/server_groups/servers/pgagent/steps/static/js/pga_jobstep'), 'pgadmin.node.pga_schedule': path.join(__dirname, './pgadmin/browser/server_groups/servers/pgagent/schedules/static/js/pga_schedule'), + 'pgadmin.node.pgt_chain': path.join(__dirname, './pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain'), + 'pgadmin.node.pgt_chaintask': path.join(__dirname, './pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask'), 'pgadmin.node.primary_key': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/primary_key'), 'pgadmin.node.procedure': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/functions/static/js/procedure'), 'pgadmin.node.resource_group': path.join(__dirname, './pgadmin/browser/server_groups/servers/resource_groups/static/js/resource_group'), From 1acdd043dd87d58b0209f59a7a556c1b8736576f Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Tue, 14 Jul 2026 01:51:58 -0400 Subject: [PATCH 03/19] Fix coderrabbit raised issues - Fix: Unguarded query status before indexing res['rows'][0] - Rollback when the post-create lookup fails. - Fix: database_connection update isn't gated by has_connstr - Add a pg_timetable specific row level threshold --- .../browser/register_browser_preferences.py | 11 +++++++++++ .../servers/pg_timetable/__init__.py | 15 +++++++++++---- .../servers/pg_timetable/tasks/__init__.py | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/web/pgadmin/browser/register_browser_preferences.py b/web/pgadmin/browser/register_browser_preferences.py index d2fe51dc5fa..1a83fc71312 100644 --- a/web/pgadmin/browser/register_browser_preferences.py +++ b/web/pgadmin/browser/register_browser_preferences.py @@ -130,6 +130,17 @@ def register_browser_preferences(self): ) ) + self.pgtimetable_row_threshold = self.preference.register( + 'properties', 'pgtimetable_row_threshold', + gettext("Maximum pgTimeTable history rows"), 'integer', 250, + category_label=gettext('Properties'), + min_val=1, max_val=9999, + help_str=gettext( + 'The maximum number of history rows to show on ' + 'the Statistics tab for pgTimeTable chains and tasks.' + ) + ) + self.table_row_count_threshold = self.preference.register( 'processes', 'process_retain_days', gettext("Process details/logs retention days"), 'integer', 5, diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index 2d41a7e4563..1bcdca5242f 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -74,6 +74,8 @@ def backend_supported(self, manager, **kwargs): column_name='database_connection' ) has_connstr""") + if not status: + return False manager.db_info['timetable'] = res['rows'][0] return True return False @@ -329,10 +331,14 @@ def create(self, gid, sid): ) ) - self.conn.execute_void('END') if not status: + self.conn.execute_void('ROLLBACK') return internal_server_error(errormsg=res) + status, commit_res = self.conn.execute_void('COMMIT') + if not status: + return internal_server_error(errormsg=commit_res) + row = res['rows'][0] return jsonify( @@ -407,6 +413,7 @@ def _process_ctasks(self, chain_id, ctasks): (tid, chain_id) ) + has_connstr = self.manager.db_info['timetable']['has_connstr'] for task in ctasks.get('changed', []): if not isinstance(task, dict): continue @@ -419,9 +426,10 @@ def _process_ctasks(self, chain_id, ctasks): 'task_name': 'task_name', 'task_order': 'task_order', 'command': 'command', - 'database_connection': 'database_connection', 'ignore_error': 'ignore_error', } + if has_connstr: + field_map['database_connection'] = 'database_connection' if 'kind' in task: sets.append("kind = %s::timetable.command_kind") params.append(task['kind']) @@ -436,7 +444,6 @@ def _process_ctasks(self, chain_id, ctasks): if 'parameters' in task: self._upsert_task_params(tid, task['parameters']) - has_connstr = self.manager.db_info['timetable']['has_connstr'] for task in ctasks.get('added', []): if not isinstance(task, dict): continue @@ -544,7 +551,7 @@ def statistics(self, gid, sid, chain_id): """ pref = Preferences.module('browser') rows_threshold = pref.preference( - 'pgagent_row_threshold' + 'pgtimetable_row_threshold' ) status, res = self.conn.execute_dict( diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py index 7d4940c813d..c691071a6a8 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -575,7 +575,7 @@ def statistics(self, gid, sid, chain_id, task_id): """ pref = Preferences.module('browser') rows_threshold = pref.preference( - 'pgagent_row_threshold' + 'pgtimetable_row_threshold' ) status, res = self.conn.execute_dict( From 4944a5ffb3c4d7de8c40370094639a5be1145bb1 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Tue, 14 Jul 2026 02:49:22 -0400 Subject: [PATCH 04/19] More fixes addressing coderabbitai review - Address status failures on tuple updates in _process_ctasks(), _upsert_task_params(), _upsert_task_params() - Do not return ok on partial chain-field UPDATE failures - Unguarded query status before indexing res['rows'][0] in check_precondition() - kind should not be represented as boolean, should be cast to text - Only insert to database_connection column if has_connstr --- .../servers/pg_timetable/__init__.py | 52 +++++++++++++++---- .../servers/pg_timetable/tasks/__init__.py | 4 +- .../templates/macros/pgt_chaintask.macros | 24 ++------- .../pgt_chaintask/sql/default/nodes.sql | 2 +- 4 files changed, 50 insertions(+), 32 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index 1bcdca5242f..44cfad99aa7 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -359,6 +359,10 @@ def update(self, gid, sid, chain_id): request.data.decode('utf-8') ) + status, res = self.conn.execute_void('BEGIN') + if not status: + return internal_server_error(errormsg=res) + chain_fields = {k: data[k] for k in ['chain_name', 'live', 'max_instances', 'timeout', 'self_destruct', 'exclusive_execution', 'client_name', 'on_error', 'run_at'] if k in data} if chain_fields: sets = [] @@ -374,9 +378,13 @@ def update(self, gid, sid, chain_id): sql = f"UPDATE timetable.chain SET {', '.join(sets)} WHERE chain_id = %s" status, res = self.conn.execute_void(sql, params) if not status: + self.conn.execute_void('ROLLBACK') return internal_server_error(errormsg=res) - self._process_ctasks(chain_id, data.get('ctasks', {})) + status, res = self._process_ctasks(chain_id, data.get('ctasks', {})) + if not status: + self.conn.execute_void('ROLLBACK') + return internal_server_error(errormsg=res) status, res = self.conn.execute_dict( render_template( @@ -386,8 +394,13 @@ def update(self, gid, sid, chain_id): ) if not status: + self.conn.execute_void('ROLLBACK') return internal_server_error(errormsg=res) + status, commit_res = self.conn.execute_void('COMMIT') + if not status: + return internal_server_error(errormsg=commit_res) + row = res['rows'][0] return jsonify( @@ -403,15 +416,17 @@ def update(self, gid, sid, chain_id): def _process_ctasks(self, chain_id, ctasks): if not isinstance(ctasks, dict): - return + return True, None for task in ctasks.get('deleted', []): tid = task.get('task_id') if isinstance(task, dict) else task if tid: - self.conn.execute_void( + status, res = self.conn.execute_void( "DELETE FROM timetable.task WHERE task_id = %s AND chain_id = %s", (tid, chain_id) ) + if not status: + return status, res has_connstr = self.manager.db_info['timetable']['has_connstr'] for task in ctasks.get('changed', []): @@ -440,9 +455,13 @@ def _process_ctasks(self, chain_id, ctasks): if sets: params.extend([tid, chain_id]) sql = f"UPDATE timetable.task SET {', '.join(sets)} WHERE task_id = %s AND chain_id = %s" - self.conn.execute_void(sql, params) + status, res = self.conn.execute_void(sql, params) + if not status: + return status, res if 'parameters' in task: - self._upsert_task_params(tid, task['parameters']) + status, res = self._upsert_task_params(tid, task['parameters']) + if not status: + return status, res for task in ctasks.get('added', []): if not isinstance(task, dict): @@ -461,19 +480,27 @@ def _process_ctasks(self, chain_id, ctasks): placeholders = ', '.join(['%s'] * len(values)) sql = f"INSERT INTO timetable.task ({', '.join(fields)}) VALUES ({placeholders}) RETURNING task_id" status, tid = self.conn.execute_scalar(sql, values) - if status and tid: - self._upsert_task_params(tid, task.get('parameters', [])) + if not status: + return status, tid + if tid: + status, res = self._upsert_task_params(tid, task.get('parameters', [])) + if not status: + return status, res + + return True, None def _upsert_task_params(self, task_id, parameters): if not parameters: - return + return True, None if isinstance(parameters, dict): parameters = parameters.get('added', []) + parameters.get('changed', []) if not parameters: - return - self.conn.execute_void( + return True, None + status, res = self.conn.execute_void( "DELETE FROM timetable.parameter WHERE task_id = %s", (task_id,) ) + if not status: + return status, res for idx, param in enumerate(parameters): if not isinstance(param, dict): param = {'order_id': idx + 1, 'value': str(param)} @@ -490,7 +517,10 @@ def _upsert_task_params(self, task_id, parameters): except (ValueError, TypeError): sql = "INSERT INTO timetable.parameter(task_id, order_id, value) VALUES (%s, %s, to_jsonb(%s::text))" params = (task_id, order_id, val) - self.conn.execute_void(sql, params) + status, res = self.conn.execute_void(sql, params) + if not status: + return status, res + return True, None @check_precondition def delete(self, gid, sid, chain_id=None): diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py index c691071a6a8..b4f0eedab4a 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -201,7 +201,7 @@ def wrap(*args, **kwargs): self.template_path = 'pgt_chaintask/sql/default' if 'timetable' not in self.manager.db_info: - _, res = self.conn.execute_dict(""" + status, res = self.conn.execute_dict(""" SELECT EXISTS( SELECT 1 FROM information_schema.columns WHERE @@ -209,6 +209,8 @@ def wrap(*args, **kwargs): column_name='database_connection' ) has_connstr""") + if not status: + return internal_server_error(errormsg=res) self.manager.db_info['timetable'] = res['rows'][0] return f(*args, **kwargs) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros index bd53d50f724..5ac73949542 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros @@ -1,18 +1,17 @@ {% macro INSERT(has_connstr, chain_id, data, conn) -%} INSERT INTO timetable.task( - chain_id, task_name, task_order, command, kind, database_connection + chain_id, task_name, task_order, command, kind{% if has_connstr %}, database_connection{% endif %} {% if data.ignore_error is defined %}, ignore_error{% endif %} ) VALUES ( {{ chain_id|qtLiteral(conn) }}::integer, {{ data.task_name|qtLiteral(conn) }}::text, {{ data.task_order|qtLiteral(conn) }}::integer, {{ data.command|qtLiteral(conn) }}::text, - {{ data.kind|qtLiteral(conn) }}::timetable.command_kind - {% if has_connstr and data.database_connection %}, - {{ data.database_connection|qtLiteral(conn) }}::text{% else %}NULL{% endif %} + {{ data.kind|qtLiteral(conn) }}::timetable.command_kind, + {% if has_connstr %}, {% if data.database_connection %}{{ data.database_connection|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% endif %} {% if data.ignore_error is defined %}, {% if data.ignore_error %}true{% else %}false{% endif %}{% endif %} -) RETURNING task_id; +) RETURNING task_id {%- endmacro %} {% macro UPDATE(has_connstr, chain_id, task_id, data, conn) -%} {% set has_task_fields = 'task_name' in data or 'task_order' in data or 'command' in data or 'kind' in data or 'ignore_error' in data or 'database_connection' in data %} @@ -47,20 +46,7 @@ SELECT t.task_id, t.chain_id, t.task_name, t.task_order, t.kind::text AS kind, t.command, t.database_connection, t.ignore_error, - COALESCE( - ( - SELECT jsonb_agg( - jsonb_build_object( - 'order_id', p.order_id, - 'value', p.value #>> '{}' - ) - ORDER BY p.order_id - ) - FROM timetable.parameter p - WHERE p.task_id = t.task_id - ), - '[]'::jsonb - ) AS parameters + NULL AS parameters FROM timetable.task t WHERE diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql index a5414816331..a37c9983f98 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql @@ -1,6 +1,6 @@ SELECT task_id, chain_id, task_name, task_order, - CASE WHEN kind::text = 'SQL' THEN true ELSE false END AS kind + kind::text AS kind FROM timetable.task WHERE From 848d20aac381118832291a98b8d3e52c482cf74c Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Tue, 14 Jul 2026 14:06:08 -0400 Subject: [PATCH 05/19] Coderabbitai review bug and other fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix the conditional commas in the INSERT values list of chain.tasks - _init_ is not __init__ — this initializer never runs. - Prevent updating non-existent database_connection column - Another unguarded check in pg_timetable init - Change terminology from pgAgent Jobs/steps to pgTimetable Chain/tasks - Make task ordering less fragile - Fix some issues with parameter update when reordering --- .../servers/pg_timetable/__init__.py | 42 +++--- .../pg_timetable/static/js/pgt_chain.ui.js | 10 +- .../servers/pg_timetable/tasks/__init__.py | 120 ++++++++++-------- .../tasks/static/js/pgt_chaintask.ui.js | 17 ++- .../templates/macros/pgt_chaintask.macros | 16 ++- .../pgt_chaintask/sql/default/create.sql | 3 +- 6 files changed, 128 insertions(+), 80 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index 44cfad99aa7..68a30a2f6b8 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -170,7 +170,7 @@ def wrap(self, *args, **kwargs): self.template_path = 'pgt_chain/sql/default' if 'timetable' not in self.manager.db_info: - _, res = self.conn.execute_dict(""" + status, res = self.conn.execute_dict(""" SELECT EXISTS( SELECT 1 FROM information_schema.columns WHERE @@ -178,6 +178,8 @@ def wrap(self, *args, **kwargs): column_name='database_connection' ) has_connstr""") + if not status: + return internal_server_error(errormsg=res) self.manager.db_info['timetable'] = res['rows'][0] return f(self, *args, **kwargs) @@ -271,7 +273,7 @@ def properties(self, gid, sid, chain_id=None): @check_precondition def create(self, gid, sid): - """Create the pgAgent job.""" + """Create the pgTimeTable chain.""" required_args = [ 'chain_name' ] @@ -492,21 +494,15 @@ def _process_ctasks(self, chain_id, ctasks): def _upsert_task_params(self, task_id, parameters): if not parameters: return True, None - if isinstance(parameters, dict): - parameters = parameters.get('added', []) + parameters.get('changed', []) - if not parameters: - return True, None - status, res = self.conn.execute_void( - "DELETE FROM timetable.parameter WHERE task_id = %s", (task_id,) - ) - if not status: - return status, res - for idx, param in enumerate(parameters): + + def _insert_param(idx, param): if not isinstance(param, dict): param = {'order_id': idx + 1, 'value': str(param)} + param.pop('_t', None) order_id = param.get('order_id') if order_id is None: order_id = idx + 1 + order_id = int(order_id) val = param.get('value', '') if val is None: val = '' @@ -517,14 +513,28 @@ def _upsert_task_params(self, task_id, parameters): except (ValueError, TypeError): sql = "INSERT INTO timetable.parameter(task_id, order_id, value) VALUES (%s, %s, to_jsonb(%s::text))" params = (task_id, order_id, val) - status, res = self.conn.execute_void(sql, params) + return self.conn.execute_void(sql, params) + + status, res = self.conn.execute_void( + "DELETE FROM timetable.parameter WHERE task_id = %s", (task_id,) + ) + if not status: + return status, res + + if isinstance(parameters, dict): + all_params = parameters.get('changed', []) + parameters.get('added', []) + else: + all_params = parameters + + for idx, param in enumerate(all_params): + status, res = _insert_param(idx, param) if not status: return status, res return True, None @check_precondition def delete(self, gid, sid, chain_id=None): - """Delete the pgAgent Job.""" + """Delete the pgTimeTable chain.""" if chain_id is None: data = request.form if request.form else json.loads( @@ -646,8 +656,8 @@ def sql(self, gid, sid, chain_id): @check_precondition def run_now(self, gid, sid, chain_id): """ - This function will set the next run to now, to inform the pgAgent to - run the job now. + This function will set the next run to now, to inform pgTimeTable to + run the chain now. """ status, res = self.conn.execute_void( render_template( diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js index 4f5824540c3..a86783742cb 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js @@ -109,8 +109,14 @@ export default class PgtChainSchema extends BaseUISchema { depChange: (state, source, topState, actionObj) => { if (actionObj.type === SCHEMA_STATE_ACTIONS.ADD_ROW && state?.ctasks) { const tasks = state.ctasks; - const lastOrder = tasks.reduce((max, t) => Math.max(max, t.task_order || 0), 0); - tasks[tasks.length - 1].task_order = lastOrder + 10; + const addedTask = actionObj.value?.cid + ? tasks.find(t => t.cid === actionObj.value.cid) + : tasks[tasks.length - 1]; + + if (addedTask && (addedTask.task_order === undefined || addedTask.task_order === null)) { + const lastOrder = tasks.reduce((max, t) => Math.max(max, parseInt(t.task_order, 10) || 0), 0); + addedTask.task_order = lastOrder + 10; + } } return state; }, diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py index b4f0eedab4a..a57a02471a0 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -7,7 +7,7 @@ # ########################################################################## -"""Implements pgAgent Job Step Node""" +"""Implements pgTimeTable Chain Task Node""" import json from functools import wraps @@ -107,7 +107,7 @@ class ChainTaskView(PGChildNodeView) A view class for ChainTask node derived from PGChildNodeView. This class is responsible for all the stuff related to view like - updating job step node, showing properties, showing sql in sql pane. + updating chain task, showing properties, showing sql in sql pane. Methods: ------- @@ -120,29 +120,29 @@ class ChainTaskView(PGChildNodeView) manager,conn & template_path properties to self * list() - - This function is used to list all the job step nodes within that + - This function is used to list all the chain tasks within that collection. * nodes() - This function will used to create all the child node within that collection. - Here it will create all the job step node. + Here it will create all the chain task nodes. * properties(gid, sid, chain_id, task_id) - - This function will show the properties of the selected job step node + - This function will show the properties of the selected chain task * update(gid, sid, chain_id, task_id) - - This function will update the data for the selected job step node + - This function will update the data for the selected chain task * msql(gid, sid, chain_id, task_id) - This function is used to return modified SQL for the selected - job step node + chain task * sql(gid, sid, chain_id, jscid) - Dummy response for sql panel * delete(gid, sid, chain_id, jscid) - - Drops job step + - Drops chain task """ node_type = blueprint.node_type @@ -168,7 +168,7 @@ class ChainTaskView(PGChildNodeView) 'stats': [{'get': 'statistics'}] }) - def _init_(self, **kwargs): + def __init__(self, **kwargs): """ Method is used to initialize the ChainTaskView and its base view. Initialize all the variables create/used dynamically like conn, @@ -220,13 +220,13 @@ def wrap(*args, **kwargs): @check_precondition def list(self, gid, sid, chain_id): """ - This function is used to list all the job step nodes within + This function is used to list all the chain tasks within that collection. Args: gid: Server Group ID sid: Server ID - chain_id: Job ID + chain_id: Chain ID """ sql = render_template( "/".join([self.template_path, self._PROPERTIES_SQL]), @@ -253,12 +253,12 @@ def nodes(self, gid, sid, chain_id, task_id=None): """ This function is used to create all the child nodes within the collection. - Here it will create all the job step nodes. + Here it will create all the chain tasks. Args: gid: Server Group ID sid: Server ID - chain_id: Job ID + chain_id: Chain ID """ res = [] sql = render_template( @@ -311,12 +311,12 @@ def nodes(self, gid, sid, chain_id, task_id=None): @check_precondition def properties(self, gid, sid, chain_id, task_id): """ - This function will show the properties of the selected job step node. + This function will show the properties of the selected chain task. Args: gid: Server Group ID sid: Server ID - chain_id: Job ID + chain_id: Chain ID task_id: ChainTask ID """ sql = render_template( @@ -346,27 +346,16 @@ def properties(self, gid, sid, chain_id, task_id): @check_precondition def create(self, gid, sid, chain_id): """ - This function will update the data for the selected job step node. - - Args: - gid: Server Group ID - sid: Server ID - chain_id: Job ID + This function will create the chain task. """ - data = {} - if request.args: - for k, v in request.args.items(): - try: - data[k] = json.loads( - v.decode('utf-8') if hasattr(v, 'decode') else v - ) - except ValueError: - data[k] = v - else: - data = json.loads(request.data.decode()) + data = request.form if request.form else json.loads( + request.data.decode('utf-8') + ) if 'parameters' in data: params_raw = data.get('parameters', []) + if isinstance(params_raw, str): + params_raw = json.loads(params_raw) if isinstance(params_raw, dict): params_raw = params_raw.get('added', []) + params_raw.get('changed', []) cleaned = [] @@ -409,7 +398,7 @@ def create(self, gid, sid, chain_id): if len(res['rows']) == 0: return gone( errormsg=gettext( - "Job step creation failed." + "Chain task creation failed." ) ) row = res['rows'][0] @@ -425,12 +414,12 @@ def create(self, gid, sid, chain_id): @check_precondition def update(self, gid, sid, chain_id, task_id): """ - This function will update the data for the selected job step node. + This function will update the data for the selected chain task. Args: gid: Server Group ID sid: Server ID - chain_id: Job ID + chain_id: Chain ID task_id: ChainTask ID """ data = request.form if request.form else json.loads( @@ -439,19 +428,26 @@ def update(self, gid, sid, chain_id, task_id): if 'parameters' in data: params_raw = data.get('parameters', []) + if isinstance(params_raw, str): + params_raw = json.loads(params_raw) + if isinstance(params_raw, dict): - params_raw = params_raw.get('added', []) + params_raw.get('changed', []) + params_list = params_raw.get('changed', []) + params_raw.get('added', []) + else: + params_list = params_raw + cleaned = [] - for idx, param in enumerate(params_raw): + for idx, param in enumerate(params_list): if not isinstance(param, dict): - cleaned.append({'order_id': idx + 1, 'value': str(param), '_is_json': False}) - else: - try: - json.loads(param.get('value', '')) - param['_is_json'] = True - except (ValueError, TypeError): - param['_is_json'] = False - cleaned.append(param) + param = {'order_id': idx + 1, 'value': str(param)} + param.pop('_t', None) + param.setdefault('value', '') + try: + json.loads(param['value']) + param['_is_json'] = True + except (ValueError, TypeError): + param['_is_json'] = False + cleaned.append(param) data['parameters'] = cleaned sql = render_template( @@ -482,7 +478,7 @@ def update(self, gid, sid, chain_id, task_id): if len(res['rows']) == 0: return gone( errormsg=gettext( - "Job step update failed." + "Chain task update failed." ) ) row = res['rows'][0] @@ -498,7 +494,7 @@ def update(self, gid, sid, chain_id, task_id): @check_precondition def delete(self, gid, sid, chain_id, task_id=None): - """Delete the Job step.""" + """Delete the chain task.""" if task_id is None: data = request.form if request.form else json.loads( @@ -523,13 +519,13 @@ def delete(self, gid, sid, chain_id, task_id=None): def msql(self, gid, sid, chain_id, task_id=None): """ This function is used to return modified SQL for the selected - job step node. + chain task. Args: gid: Server Group ID sid: Server ID - chain_id: Job ID - task_id: Job Step ID + chain_id: Chain ID + task_id: Chain Task ID """ data = {} sql = '' @@ -539,6 +535,30 @@ def msql(self, gid, sid, chain_id, task_id=None): except ValueError: data[k] = v + if 'parameters' in data: + params_raw = data.get('parameters', []) + if isinstance(params_raw, str): + params_raw = json.loads(params_raw) + + if isinstance(params_raw, dict): + params_list = params_raw.get('changed', []) + params_raw.get('added', []) + else: + params_list = params_raw + + cleaned = [] + for idx, param in enumerate(params_list): + if not isinstance(param, dict): + param = {'order_id': idx + 1, 'value': str(param)} + param.pop('_t', None) + param.setdefault('value', '') + try: + json.loads(param['value']) + param['_is_json'] = True + except (ValueError, TypeError): + param['_is_json'] = False + cleaned.append(param) + data['parameters'] = cleaned + if task_id is None: sql = render_template( "/".join([self.template_path, self._CREATE_SQL]), diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js index 27f36514c7f..c123d92a230 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js @@ -18,9 +18,9 @@ export function getNodePgtChainTaskSchema(treeNodeInfo, itemNodeData) { constructor() { super({ order_id: null, value: '' }); } - get idAttribute() { return 'order_id'; } get baseFields() { return [ + { id: '_t', type: 'boolean' }, { id: 'order_id', label: gettext('Order'), type: 'int', noEmpty: true, cell: 'int', width: 20 }, { id: 'value', label: gettext('Value'), type: 'multiline', cell: 'text' }, ]; @@ -75,9 +75,9 @@ export default class PgtChainTaskSchema extends BaseUISchema { constructor() { super({ order_id: null, value: '' }); } - get idAttribute() { return 'order_id'; } get baseFields() { return [ + { id: '_t', type: 'boolean' }, { id: 'order_id', label: gettext('Order'), type: 'int', noEmpty: true, cell: 'int', width: 20 }, { id: 'value', label: gettext('Value'), type: 'multiline', cell: 'text' }, ]; @@ -166,12 +166,21 @@ For more information, please see the documentation on 0 %}{{ data.run_at|qtLiteral(conn) }}::text{% else %}NULL{% endif %}, - {{ data.client_name|qtLiteral(conn) }}::text, + {% if data.client_name and data.client_name|length > 0 %}{{ data.client_name|qtLiteral(conn) }}::text{% else %}NULL{% endif %}, {% if data.live %}true{% else %}false{% endif %}, - {% if data.max_instances is not none %}{{ data.max_instances|qtLiteral(conn) }}::integer{% else %}NULL{% endif %}, - {% if data.timeout is not none %}{{ data.timeout|qtLiteral(conn) }}::integer{% else %}0{% endif %}, + {% if data.max_instances is defined and data.max_instances is not none %}{{ data.max_instances|qtLiteral(conn) }}::integer{% else %}NULL{% endif %}, + {% if data.timeout is defined and data.timeout is not none %}{{ data.timeout|qtLiteral(conn) }}::integer{% else %}0{% endif %}, {% if data.self_destruct %}true{% else %}false{% endif %}, {% if data.exclusive_execution %}true{% else %}false{% endif %}, {% if data.on_error and data.on_error|length > 0 %}{{ data.on_error|qtLiteral(conn) }}::text{% else %}NULL{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql index 76bd63be4f4..352a3bcdf79 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql @@ -1,6 +1,6 @@ {% import 'macros/pgt_chaintask.macros' as TASK %} WITH tid AS ( - {{ TASK.INSERT(has_connstr, chain_id, data, conn) }} + {{ TASK.INSERT(has_connstr, chain_id, data, conn) | replace(';', '') }} ) {% if 'parameters' in data and data.parameters|length > 0 %} , ins AS ( diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql index a37c9983f98..5ec42b8f519 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql @@ -8,4 +8,4 @@ WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer AND {% endif %} chain_id = {{ chain_id|qtLiteral(conn) }}::integer -ORDER BY task_order; +ORDER BY task_order, task_name; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json index 97f797aabd9..00f243eb8f9 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json @@ -68,6 +68,7 @@ "test_data": { "chain_name": "test_chain_add%s", "live": true, + "client_name": "", "ctasks": [ { "task_name": "test_task_1", diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py index 8bedfb5769e..10e7c77e1da 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py @@ -19,6 +19,7 @@ class PgtChainDeleteTestCase(BaseTestGenerator): pgt_utils.test_cases) def setUp(self): + super().setUp() flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) if not flag: self.skipTest(msg) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py index f0a8b6041d3..035e7c890f6 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py @@ -21,6 +21,7 @@ class PgtChainGetMsqlTestCase(BaseTestGenerator): pgt_utils.test_cases) def setUp(self): + super().setUp() flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) if not flag: self.skipTest(msg) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py index 726d2fb3abc..972eacebf99 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py @@ -21,6 +21,7 @@ class PgtChainGetNodesTestCase(BaseTestGenerator): pgt_utils.test_cases) def setUp(self): + super().setUp() flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) if not flag: self.skipTest(msg) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py index 731abfbe160..246aa3e34b8 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py @@ -21,6 +21,7 @@ class PgtChainSqlTestCase(BaseTestGenerator): pgt_utils.test_cases) def setUp(self): + super().setUp() flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) if not flag: self.skipTest(msg) From 0d4a981c68bb2ba56b3b59310622c2d7bfedb17e Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Wed, 15 Jul 2026 16:23:01 -0400 Subject: [PATCH 07/19] More fixes - Force tasks to be sorted by task_order using custom label that prefixes with task_order - Regress tests add super().setUp() to 6 test files that were missing it: test_pgt_chain_delete.py, test_pgt_chain_get_msql.py, test_pgt_chain_get_nodes.py, test_pgt_chain_sql.py, test_pgt_task_delete.py, test_pgt_task_get_msql.py - Guard undefined fields when inserting chains --- .../browser/server_groups/servers/pg_timetable/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index b8bff6c52d6..e5568763ede 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -369,8 +369,8 @@ def update(self, gid, sid, chain_id): if chain_fields: sets = [] params = [] - bool_keys = {'live', 'self_destruct', 'exclusive_execution'} - null_if_empty = {'run_at', 'client_name', 'on_error'} + bool_keys = ['live', 'self_destruct', 'exclusive_execution'] + null_if_empty = ['run_at', 'client_name', 'on_error'] for key, val in chain_fields.items(): sets.append(f"{key} = %s") if key in bool_keys: From 4d4babd59d703a4877a2eae92b66b75602d5f9ed Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Thu, 16 Jul 2026 09:12:57 -0400 Subject: [PATCH 08/19] Fix test issues - Close the connection if it exists even when there is a failure - Always assign a response output even if mocking is not required --- .../tasks/tests/test_pgt_task_put.py | 2 ++ .../servers/pg_timetable/tests/utils.py | 30 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py index e75e291b68e..75777c476cb 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py @@ -54,6 +54,8 @@ def runTest(self): with patch(self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])]): response = tasks_utils.api_put(self) + else + response = task_utils.api_put(self) utils.assert_status_code(self, response) utils.assert_error_message(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py index a6645520dd0..87432e45efc 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py @@ -154,6 +154,7 @@ def is_pgtimetable_installed_on_server(self): def create_pgtimetable_chain(self, name): + connection = None try: connection = utils.get_db_connection( self.server['db'], @@ -178,15 +179,18 @@ def create_pgtimetable_chain(self, name): chain_id = pg_cursor.fetchone() utils.set_isolation_level(connection, old_isolation_level) connection.commit() - connection.close() return chain_id[0] except Exception: traceback.print_exc(file=sys.stderr) + finally: + if connection: + connection.close() def delete_pgtimetable_chain(self, chain_id=None): if chain_id is None: chain_id = self.chain_id + connection = None try: connection = utils.get_db_connection( self.server['db'], @@ -209,12 +213,15 @@ def delete_pgtimetable_chain(self, chain_id=None): ) utils.set_isolation_level(connection, old_isolation_level) connection.commit() - connection.close() except Exception: traceback.print_exc(file=sys.stderr) + finally: + if connection: + connection.close() def verify_pgtimetable_chain(self): + connection = None try: connection = utils.get_db_connection( self.server['db'], @@ -231,13 +238,16 @@ def verify_pgtimetable_chain(self): ) result = pg_cursor.fetchone() count = result[0] - connection.close() return count is not None and int(count) != 0 except Exception: traceback.print_exc(file=sys.stderr) + finally: + if connection: + connection.close() def create_pgtimetable_task(self, task_name, chain_id): + connection = None try: connection = utils.get_db_connection( self.server['db'], @@ -261,15 +271,18 @@ def create_pgtimetable_task(self, task_name, chain_id): task_id = pg_cursor.fetchone() utils.set_isolation_level(connection, old_isolation_level) connection.commit() - connection.close() return task_id[0] except Exception: traceback.print_exc(file=sys.stderr) + finally: + if connection: + connection.close() def delete_pgtimetable_task(self, task_id=None): if task_id is None: task_id = self.task_id + connection = None try: connection = utils.get_db_connection( self.server['db'], @@ -292,12 +305,15 @@ def delete_pgtimetable_task(self, task_id=None): ) utils.set_isolation_level(connection, old_isolation_level) connection.commit() - connection.close() except Exception: traceback.print_exc(file=sys.stderr) + finally: + if connection: + connection.close() def verify_pgtimetable_task(self): + connection = None try: connection = utils.get_db_connection( self.server['db'], @@ -314,7 +330,9 @@ def verify_pgtimetable_task(self): ) result = pg_cursor.fetchone() count = result[0] - connection.close() return count is not None and int(count) != 0 except Exception: traceback.print_exc(file=sys.stderr) + finally: + if connection: + connection.close() From 778c2e0f461acbceae4347d10bd15ccca310d153 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Thu, 16 Jul 2026 16:12:40 -0400 Subject: [PATCH 09/19] Take care of issues raised during code review - run_now - when no client_name specified in the chain, run on all active agents - if no active agent and no client_name specified in chain, raise an error - fix missing : - fix parsing / deparsing of json data in timetable.parameter --- .../servers/pg_timetable/__init__.py | 15 ++++++++ .../tasks/tests/test_pgt_task_put.py | 2 +- .../pgt_chain/sql/default/run_now.sql | 34 ++++++++++++++----- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index e5568763ede..9b613aabc7b 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -572,6 +572,21 @@ def msql(self, gid, sid, chain_id=None): except ValueError: data[k] = v + # Preserve _is_json marker for JSON parameter values in ctasks + if isinstance(data.get('ctasks'), list): + for task in data['ctasks']: + if not isinstance(task, dict): + continue + for param in task.get('parameters', []): + if not isinstance(param, dict): + continue + val = param.get('value') + if isinstance(val, (dict, list)): + param['value'] = json.dumps(val, indent=2) + param['_is_json'] = True + elif '_is_json' not in param: + param['_is_json'] = False + return make_json_response( data=render_template( "/".join([ diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py index 75777c476cb..4f9ffe05bdc 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py @@ -54,7 +54,7 @@ def runTest(self): with patch(self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])]): response = tasks_utils.api_put(self) - else + else: response = task_utils.api_put(self) utils.assert_status_code(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql index 018aa3f2d87..e08ff800457 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql @@ -1,8 +1,26 @@ -SELECT timetable.notify_chain_start( - {{ chain_id|qtLiteral(conn) }}::bigint, - ( - SELECT client_name - FROM timetable.chain - WHERE chain_id = {{ chain_id|qtLiteral(conn) }}::integer - ) -) AS result; +DO $$ +DECLARE + _client_name text; + _chain_id bigint := {{ chain_id|qtLiteral(conn) }}::bigint; + _found boolean := false; +BEGIN + SELECT client_name INTO _client_name + FROM timetable.chain + WHERE chain_id = _chain_id; + + IF _client_name IS NULL OR _client_name = '' THEN + FOR _client_name IN + SELECT DISTINCT client_name FROM timetable.active_session + LOOP + _found := true; + PERFORM timetable.notify_chain_start(_chain_id, _client_name); + END LOOP; + + IF NOT _found THEN + RAISE EXCEPTION 'no client_name specified and no active sessions'; + END IF; + ELSE + PERFORM timetable.notify_chain_start(_chain_id, _client_name); + END IF; +END +$$; From 93732bcbdc96d511c8586fd43c16f12d95541a47 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Thu, 16 Jul 2026 23:28:35 -0400 Subject: [PATCH 10/19] More issues raised in code review - utils is_pgtimetable_installed_on_server should return exception message and format of output should be same structure as the success case --- .../browser/server_groups/servers/pg_timetable/tests/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py index 87432e45efc..da2ba04a18b 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py @@ -151,6 +151,7 @@ def is_pgtimetable_installed_on_server(self): return True, None except Exception: traceback.print_exc(file=sys.stderr) + return False, "Error checking pgTimetable installation." def create_pgtimetable_chain(self, name): From 2ab2c5f08e2d3af0886adb15d2a88b40eeaeb2bd Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Thu, 16 Jul 2026 23:59:37 -0400 Subject: [PATCH 11/19] - Fix typo on tasks_utils in tests - Change task label from " " to ": " - Change run_now so it doesn't run on all active agents. Should only run on one that is active. Change wording from active session to active agent --- .../servers/pg_timetable/tasks/__init__.py | 8 ++++---- .../tasks/tests/test_pgt_task_put.py | 2 +- .../templates/pgt_chain/sql/default/run_now.sql | 17 ++++++----------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py index 4481254116f..335fe903f41 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -278,7 +278,7 @@ def nodes(self, gid, sid, chain_id, task_id=None): return gone(errormsg=self.STEP_NOT_FOUND) row = result['rows'][0] - label = f"{int(row['task_order']):04d} {row['task_name']}" + label = f"{int(row['task_order']):04d}: {row['task_name']}" node = self.blueprint.generate_browser_node( row['task_id'], row['chain_id'], @@ -291,7 +291,7 @@ def nodes(self, gid, sid, chain_id, task_id=None): return make_json_response(node) for row in result['rows']: - label = f"{int(row['task_order']):04d} {row['task_name']}" + label = f"{int(row['task_order']):04d}: {row['task_name']}" node = self.blueprint.generate_browser_node( row['task_id'], row['chain_id'], @@ -402,7 +402,7 @@ def create(self, gid, sid, chain_id): ) ) row = res['rows'][0] - label = f"{int(row['task_order']):04d} {row['task_name']}" + label = f"{int(row['task_order']):04d}: {row['task_name']}" node = self.blueprint.generate_browser_node( row['task_id'], row['chain_id'], @@ -482,7 +482,7 @@ def update(self, gid, sid, chain_id, task_id): ) ) row = res['rows'][0] - label = f"{int(row['task_order']):04d} {row['task_name']}" + label = f"{int(row['task_order']):04d}: {row['task_name']}" node = self.blueprint.generate_browser_node( task_id, chain_id, diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py index 4f9ffe05bdc..79b52887c4a 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py @@ -55,7 +55,7 @@ def runTest(self): side_effect=[eval(self.mock_data["return_value"])]): response = tasks_utils.api_put(self) else: - response = task_utils.api_put(self) + response = tasks_utils.api_put(self) utils.assert_status_code(self, response) utils.assert_error_message(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql index e08ff800457..d1f7f15fcd3 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql @@ -2,25 +2,20 @@ DO $$ DECLARE _client_name text; _chain_id bigint := {{ chain_id|qtLiteral(conn) }}::bigint; - _found boolean := false; BEGIN SELECT client_name INTO _client_name FROM timetable.chain WHERE chain_id = _chain_id; IF _client_name IS NULL OR _client_name = '' THEN - FOR _client_name IN - SELECT DISTINCT client_name FROM timetable.active_session - LOOP - _found := true; - PERFORM timetable.notify_chain_start(_chain_id, _client_name); - END LOOP; + SELECT client_name INTO _client_name + FROM timetable.active_session LIMIT 1; - IF NOT _found THEN - RAISE EXCEPTION 'no client_name specified and no active sessions'; + IF NOT FOUND THEN + RAISE EXCEPTION 'no client_name specified and no active agents'; END IF; - ELSE - PERFORM timetable.notify_chain_start(_chain_id, _client_name); END IF; + + PERFORM timetable.notify_chain_start(_chain_id, _client_name); END $$; From da880e0ee287c124477248c9197f0518d4025c45 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Fri, 17 Jul 2026 02:47:01 -0400 Subject: [PATCH 12/19] - Fix test logic for negative scenarios and missing negative test handling. - Add negative tests --- .../tasks/tests/tasks_test_data.json | 18 ++++++++++++++++++ .../tasks/tests/test_pgt_task_delete.py | 18 ++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json index bcf809a184b..1017e50461f 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json @@ -99,6 +99,24 @@ "test_result_data": {} }, "is_list": true + }, + { + "name": "Delete task: With existing task while server is down.", + "url": "/browser/pgt_chaintask/obj/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": {}, + "mocking_required": true, + "mock_data": { + "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_void", + "return_value": "(False,'Mocked Internal Server Error')" + }, + "expected_data": { + "status_code": 500, + "error_msg": "Mocked Internal Server Error", + "test_result_data": {} + }, + "is_list": false } ], "pgt_task_put": [ diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py index 78d9bc43556..bd5c81e4b99 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py @@ -8,6 +8,8 @@ ########################################################################## import uuid +from unittest.mock import patch + from pgadmin.utils.route import BaseTestGenerator from regression.python_test_utils import test_utils as utils from pgadmin.browser.server_groups.servers.pg_timetable.tests import \ @@ -54,10 +56,18 @@ def runTest(self): utils.assert_status_code(self, response) - is_present = pgt_utils.verify_pgtimetable_task(self) - self.assertFalse( - is_present, - "pgTimetable task was not deleted successfully") + is_present = pgt_utils.verify_pgtimetable_task(self) + self.assertFalse( + is_present, + "pgTimetable task was not deleted successfully") + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])]): + response = tasks_utils.api_delete(self) + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) def tearDown(self): """Clean up code""" From 44a52d7ac12b8673a7086223978a51d2d0f040fc Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Fri, 17 Jul 2026 03:32:03 -0400 Subject: [PATCH 13/19] pgTimeTable docs --- .../images/pgtimetable_chain_general.png | Bin 0 -> 57141 bytes .../images/pgtimetable_chain_properties.png | Bin 0 -> 29790 bytes docs/en_US/images/pgtimetable_task_code.png | Bin 0 -> 15341 bytes .../en_US/images/pgtimetable_task_general.png | Bin 0 -> 48578 bytes .../images/pgtimetable_task_parameters.png | Bin 0 -> 18567 bytes .../images/pgtimetable_task_statistics.png | Bin 0 -> 18530 bytes docs/en_US/index.rst | 1 + docs/en_US/pgtimetable_chains.rst | 241 ++++++++++++++++++ 8 files changed, 242 insertions(+) create mode 100644 docs/en_US/images/pgtimetable_chain_general.png create mode 100644 docs/en_US/images/pgtimetable_chain_properties.png create mode 100644 docs/en_US/images/pgtimetable_task_code.png create mode 100644 docs/en_US/images/pgtimetable_task_general.png create mode 100644 docs/en_US/images/pgtimetable_task_parameters.png create mode 100644 docs/en_US/images/pgtimetable_task_statistics.png create mode 100644 docs/en_US/pgtimetable_chains.rst diff --git a/docs/en_US/images/pgtimetable_chain_general.png b/docs/en_US/images/pgtimetable_chain_general.png new file mode 100644 index 0000000000000000000000000000000000000000..4cde1249072e441b1b0c09dcf08b6b6549fc56ab GIT binary patch literal 57141 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 KelF{r5}E*2Yfwc1 literal 0 HcmV?d00001 diff --git a/docs/en_US/images/pgtimetable_chain_properties.png b/docs/en_US/images/pgtimetable_chain_properties.png new file mode 100644 index 0000000000000000000000000000000000000000..3ca9b61918118fed3bf9e221a6fd9ac0ec63ffb1 GIT binary patch literal 29790 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 literal 0 HcmV?d00001 diff --git a/docs/en_US/images/pgtimetable_task_code.png b/docs/en_US/images/pgtimetable_task_code.png new file mode 100644 index 0000000000000000000000000000000000000000..928c276e3dd25ae7d0b2d688343b37c214fb8a23 GIT binary patch literal 15341 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 literal 0 HcmV?d00001 diff --git a/docs/en_US/images/pgtimetable_task_general.png b/docs/en_US/images/pgtimetable_task_general.png new file mode 100644 index 0000000000000000000000000000000000000000..e5a5f9e49499700af281057fa13e0fa2b4ac0958 GIT binary patch literal 48578 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 RxcgN>in6LQ`_. + +If you are migrating from pgAgent or another PostgreSQL based scheduler, pgTimeTable provides +migration SQL queries to help in that migration. See the +`migration tools `_ +on GitHub for details. + +Viewing pgTimeTable +******************** +To enable viewing of the pgTimeTable node, enable it on the File->Preferences->Nodes. +It will only show if the schema *timetable* and associated tables exists in your maintenance database. + +Creating a Chain +**************** + +Note that pgTimeTable is not installed via `CREATE EXTENSION`. +Instead you use a pgTimeTable agent to install the necessary tables. +To create or manage a chain, use the pgAdmin tree control to browse to the +server on which the timetable schema exists. The tree control will +display a *pgTimeTable Chains* node, under which currently defined chains are +displayed. + +To add a new chain, right click on the *pgTimeTable Chains* node, +and select *Create pgTimeTable Chain...* from the context menu. + +When the chain dialog opens, use the tabs on the dialog to define the chain +and its tasks. + +.. image:: images/pgtimetable_chain_general.png + :alt: pgTimeTable chain dialog general tab + :align: center + +Use the fields on the *General* tab to provide general information about the +chain: + +* Provide a name for the chain in the *Name* field. +* Move the *Enabled* switch to the *Yes* position to enable the chain, or + *No* to disable it. +* Use the *Max instances* field to specify the number of parallel instances + (clients) that may execute this chain simultaneously. Leave the field blank + to allow unlimited parallel execution. +* Use the *Timeout (ms)* field to specify the maximum number of milliseconds + the chain may run before it is aborted. A value of *0* means no timeout. +* Move the *Self-destruct* switch to *Yes* to have the chain delete itself + after a successful run. +* Move the *Exclusive execution* switch to *Yes* to pause all other parallel + chains while this chain is executing. +* Use the *Host agent* field to specify the client name of the agent running + pgTimeTable that should execute this chain. Leave the field blank to allow + any available host to run the chain. + + .. note:: You can query the ``timetable.active_session`` view to see the + client names reported by running pgTimeTable agents: + + .. code-block:: sql + + SELECT DISTINCT client_name FROM timetable.active_session + + Use the client name exactly as reported by the query in the *Host agent* + field. + +* Use the *On error* field to specify the behavior when an error occurs. + Accepted values are: + + * *NULL* - Use the default error handling. + * *LOG* - Log the error and continue execution. + * *ABORT* - Abort the chain on error. + +* Use the *Schedule* field to specify a cron-style schedule for the chain. + The format consists of a single field with 5 slots. As with regular cron + you can use a , to run multiple and wildcard for all. + E.g. `20 1,5 * * *` would run at 1:20 and 5:20 AM. + The timezone is always the timezone of the server pgTimetable database resides on. + + .. code-block:: text + + * * * * * + ┬ ┬ ┬ ┬ ┬ + │ │ │ │ │ + │ │ │ │ └──── day of the week (0 - 7) (0 and 7 = Sunday) + │ │ │ └────── month (1 - 12) + │ │ └──────── day of the month (1 - 31) + │ └────────── hour (0 - 23) + └──────────── minute (0 - 59) + + Leave the field blank to disable automatic scheduling. The chain can still + be started manually using the *Run now* option. + +In addition to cron syntax, pgTimeTable supports additional special case formats. +Namely `@after`, `@every`, `@reboot`. + +The `@every` and `@after` should be followed with additional arguments. +For example `@every 2 hours`. + + +.. image:: images/pgtimetable_chain_tasks.png + :alt: pgTimeTable chain dialog tasks tab + :align: center + +Use the *Tasks* tab to define and manage the tasks that the chain will +execute. Click the Add icon (+) to add a new task. Tasks are executed in +order of their *Order* value. + +Click the compose icon (located at the left side of the header) to open the +task definition dialog. See `Chain Task Dialog`_ below for details on +defining a task. + +When you have finished defining the chain, you can use the *SQL* tab to review +the SQL that will create or modify your chain. + +Click the *Save* button to save the chain, or *Close* to exit without saving. +Use the *Reset* button to remove your unsaved entries from the dialog. + +After saving a chain, the chain will be listed under the *pgTimeTable Chains* +node of the pgAdmin tree control. The *Properties* tab in the main pgAdmin +window will display a high-level overview of the selected chain, and the +*Statistics* tab will show the details of each execution run. + +.. image:: images/pgtimetable_chain_properties.png + :alt: pgTimeTable chain properties + :align: center + +To modify an existing chain or to review detailed information about a chain, +right-click on a chain name, and select *Properties* from the context menu. + + +Chain Task Dialog +***************** + +A chain task defines a single unit of work to be performed as part of a chain. +Each chain can contain one or more tasks, which are executed sequentially in +order. + +.. image:: images/pgtimetable_task_general.png + :alt: pgTimeTable task dialog general tab + :align: center + +Use the fields on the *General* tab to define the task: + +* Provide a name for the task in the *Name* field. +* Use the *Order* field to specify the execution order of the task within the + chain. Tasks are executed in ascending order by this value. +* Use the *Kind* drop-down to select the type of task: + + * *SQL* - The task executes SQL statements. + * *PROGRAM* - The task executes an operating system command. + * *BUILTIN* - The task executes one of the built-in pgTimeTable commands. + +* When *Kind* is set to *SQL*, use the *Connection string* field to optionally + specify a libpq-style connection string for a remote database. Leave the + field blank to execute against the pgTimeTable database. + + .. note:: The *Connection string* field is only available when the + pgTimeTable extension version supports it (version 5.x or later). + It is ignored for *PROGRAM* and *BUILTIN* task kinds. + +* Move the *Ignore error* switch to *Yes* to continue chain execution even if + this task fails. Set to *No* to stop the chain when an error occurs. + +.. image:: images/pgtimetable_task_code.png + :alt: pgTimeTable task dialog code tab + :align: center + +Use the *Code* tab to provide the code or command for the task. The content +of the *Code* tab depends on the selected *Kind*: + +* If *Kind* is *SQL*, provide one or more SQL statements in the *SQL* field. + Use ``$1``, ``$2``, etc. as parameter placeholders that will be substituted + from the *Parameters* collection. +* If *Kind* is *PROGRAM*, provide the full path and arguments of the program + to execute in the *Program* field. +* If *Kind* is *BUILTIN*, select one of the following internal commands from + the *Internal Command* drop-down: + + * *NoOp* - No operation; the task completes immediately. + * *Sleep* - Pause execution for a duration specified by a parameter. + * *Log* - Write a message to the pgTimeTable log. + * *SendMail* - Send an email (requires parameters for recipient, subject, + and body). + * *Download* - Download a file from a URL. + * *CopyFromFile* - Copy data from a file into a database table. + * *CopyToFile* - Copy data from a database table to a file. + * *Shutdown* - Shut down the pgTimeTable service. + +.. image:: images/pgtimetable_task_parameters.png + :alt: pgTimeTable task dialog parameters + :align: center + +Use the *Parameters* sub-collection to define parameter values that will be +substituted into the task command. Click the Add icon (+) to add a new +parameter row: + +* Use the *Order* column to specify the parameter position (``$1``, ``$2``, + etc.). +* Use the *Value* column to provide the parameter value. The value may be a + plain string or a JSON value. + +Click the compose icon to close the task definition dialog, then click *Save* +on the chain dialog to save all changes. + +Refer to `pgTimeTable samples `_ +for kind of parameters expected for each type. + + +Both the Chain and Tasks have a statistics tab, which shows results of past run of tasks. + + +.. image:: images/pgtimetable_task_statistics.png + :alt: pgTimeTable task statistics + :align: center From 1a12da75b9e7b8bab655750a091eaa3340310c76 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Sat, 18 Jul 2026 03:09:58 -0400 Subject: [PATCH 14/19] Fix grammar and spelling --- docs/en_US/pgtimetable_chains.rst | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/docs/en_US/pgtimetable_chains.rst b/docs/en_US/pgtimetable_chains.rst index c2e857a2dd6..07e2ed2fbd6 100644 --- a/docs/en_US/pgtimetable_chains.rst +++ b/docs/en_US/pgtimetable_chains.rst @@ -5,25 +5,24 @@ `pgTimeTable Chains`:index: *************************************** -pgTimeTable is a PostgreSQL-based scheduling agent that runs and manages what are called `Chains` -each chain is composed of a set of Tasks to run order by the sequence they should run. +pgTimeTable is a PostgreSQL-based scheduling agent that runs and manages what are called `Chains`. +Each chain is composed of a set of Tasks to be executed in the order they should run. A chain is assigned a single schedule using an extended cron-style format. - This UI has been tested with pgTimeTable 6.3.0 and above. A task may be a series of *SQL* statements, a *BUILTIN* pgTimeTable command (such as sending email) or an operating system *PROGRAM* program/batch/shell script. Switches on the *pgTimeTable Chain* dialog (accessed -through the *Properties* context menu) allow you to modify a chain or disable the whole chain -as well as modifying tasks within the chain. +through the *Properties* context menu) allow you to modify a chain or disable the whole chain, +as well as modify tasks within the chain. When you highlight the name of a defined chain in the pgAdmin tree control, the *Properties* tab of the main pgAdmin window will display details about the chain, -and the *Statistics* tab will display details about the chains's execution. +and the *Statistics* tab will display details about the chain's execution. There are also statistics about each task. -pgTimeTable supports ability to have more than one job agent running at a time. +pgTimeTable supports the ability to have more than one job agent running at a time. Job agents can be deployed on various platforms and binaries are readily available to deploy on Linux, Windows, and Mac. @@ -38,8 +37,8 @@ on GitHub for details. Viewing pgTimeTable ******************** -To enable viewing of the pgTimeTable node, enable it on the File->Preferences->Nodes. -It will only show if the schema *timetable* and associated tables exists in your maintenance database. +To enable viewing of the *pgTimeTable Chains* node, enable it on the File -> Preferences -> Nodes. +It will only show if the schema *timetable* and associated tables exist in your maintenance database. Creating a Chain **************** @@ -98,10 +97,10 @@ chain: * *ABORT* - Abort the chain on error. * Use the *Schedule* field to specify a cron-style schedule for the chain. - The format consists of a single field with 5 slots. As with regular cron - you can use a , to run multiple and wildcard for all. + The format consists of a single field with 5 slots. As with regular cron, + you can use a comma (`,`) for multiple values and an asterisk (`*`) as a wildcard for all. E.g. `20 1,5 * * *` would run at 1:20 and 5:20 AM. - The timezone is always the timezone of the server pgTimetable database resides on. + The timezone is always the timezone of the server the pgTimeTable database resides on. .. code-block:: text @@ -230,10 +229,10 @@ Click the compose icon to close the task definition dialog, then click *Save* on the chain dialog to save all changes. Refer to `pgTimeTable samples `_ -for kind of parameters expected for each type. +for the kind of parameters expected for each type. -Both the Chain and Tasks have a statistics tab, which shows results of past run of tasks. +Both the Chain and Tasks have a statistics tab, which show the results of past runs of tasks. .. image:: images/pgtimetable_task_statistics.png From d7db1519cc8b1ff7ef8e4012b3597faedb315448 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Wed, 26 Aug 2026 12:20:49 -0400 Subject: [PATCH 15/19] MUST FIX items - Fix pycodestyle violations E305, E501 failures - Fix pycodestyle W503 warnings - Remove unnecessary | replace(';', '') in pgt_chaintast create.sql - Fix task order incrementer - Fix task parameters updating by returning parameters as json in pgt_chaintask.macros - Make sure old parameter rows are cleared - task_order in tasks should be cast as double precision, not integer and should allow decimals in UI - minor cleanup of task_order display in tree - Require command be filled in for PROGRAM and BUILTIN (not just SQL) --- .../servers/pg_timetable/__init__.py | 99 +++++++++++++++---- .../pg_timetable/static/js/pgt_chain.ui.js | 10 +- .../servers/pg_timetable/tasks/__init__.py | 53 +++++++--- .../tasks/static/js/pgt_chaintask.ui.js | 20 ++-- .../templates/macros/pgt_chaintask.macros | 15 ++- .../pgt_chain/sql/default/create.sql | 4 +- .../pgt_chaintask/sql/default/create.sql | 2 +- .../servers/pg_timetable/tests/utils.py | 5 +- 8 files changed, 156 insertions(+), 52 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index 9b613aabc7b..7d77ae96efa 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -199,7 +199,10 @@ def nodes(self, gid, sid, chain_id=None): if chain_id is not None: if len(rset['rows']) != 1: return gone( - errormsg=_("Could not find the pgTimeTable chain on the server.") + errormsg=_( + "Could not find the pgTimeTable" + " chain on the server." + ) ) return make_json_response( data=self.blueprint.generate_browser_node( @@ -254,7 +257,10 @@ def properties(self, gid, sid, chain_id=None): render_template( "/".join([self.template_path, 'tasks.sql']), chain_id=chain_id, conn=self.conn, - has_connstr=self.manager.db_info['timetable']['has_connstr'] + has_connstr=( + self.manager + .db_info['timetable']['has_connstr'] + ) ) ) if not status: @@ -298,7 +304,11 @@ def create(self, gid, sid): cleaned_params = [] for idx, param in enumerate(task.get('parameters', [])): if not isinstance(param, dict): - cleaned_params.append({'order_id': idx + 1, 'value': str(param), '_is_json': False}) + cleaned_params.append({ + 'order_id': idx + 1, + 'value': str(param), + '_is_json': False + }) else: try: json.loads(param.get('value', '')) @@ -365,7 +375,14 @@ def update(self, gid, sid, chain_id): if not status: return internal_server_error(errormsg=res) - chain_fields = {k: data[k] for k in ['chain_name', 'live', 'max_instances', 'timeout', 'self_destruct', 'exclusive_execution', 'client_name', 'on_error', 'run_at'] if k in data} + chain_fields = { + k: data[k] for k in [ + 'chain_name', 'live', 'max_instances', + 'timeout', 'self_destruct', + 'exclusive_execution', 'client_name', + 'on_error', 'run_at' + ] if k in data + } if chain_fields: sets = [] params = [] @@ -380,7 +397,11 @@ def update(self, gid, sid, chain_id): else: params.append(val) params.append(chain_id) - sql = f"UPDATE timetable.chain SET {', '.join(sets)} WHERE chain_id = %s" + sql = ( + f"UPDATE timetable.chain" + f" SET {', '.join(sets)}" + f" WHERE chain_id = %s" + ) status, res = self.conn.execute_void(sql, params) if not status: self.conn.execute_void('ROLLBACK') @@ -427,7 +448,9 @@ def _process_ctasks(self, chain_id, ctasks): tid = task.get('task_id') if isinstance(task, dict) else task if tid: status, res = self.conn.execute_void( - "DELETE FROM timetable.task WHERE task_id = %s AND chain_id = %s", + "DELETE FROM timetable.task" + " WHERE task_id = %s" + " AND chain_id = %s", (tid, chain_id) ) if not status: @@ -459,7 +482,12 @@ def _process_ctasks(self, chain_id, ctasks): params.append(task[frontend_field]) if sets: params.extend([tid, chain_id]) - sql = f"UPDATE timetable.task SET {', '.join(sets)} WHERE task_id = %s AND chain_id = %s" + sql = ( + f"UPDATE timetable.task" + f" SET {', '.join(sets)}" + f" WHERE task_id = %s" + f" AND chain_id = %s" + ) status, res = self.conn.execute_void(sql, params) if not status: return status, res @@ -471,33 +499,48 @@ def _process_ctasks(self, chain_id, ctasks): for task in ctasks.get('added', []): if not isinstance(task, dict): continue - fields = ['chain_id', 'task_name', 'task_order', 'command'] - values = [chain_id, task.get('task_name', ''), task.get('task_order', 10), task.get('command', '')] + fields = [ + 'chain_id', 'task_name', 'task_order', 'command' + ] + values = [ + chain_id, + task.get('task_name', ''), + task.get('task_order', 10), + task.get('command', '') + ] if 'ignore_error' in task: fields.append('ignore_error') values.append(task['ignore_error']) if 'kind' in task: fields.append('kind') values.append(task['kind']) - if has_connstr and 'database_connection' in task and task['database_connection']: + if ( + has_connstr and + 'database_connection' in task and + task['database_connection'] + ): fields.append('database_connection') values.append(task['database_connection']) placeholders = ', '.join(['%s'] * len(values)) - sql = f"INSERT INTO timetable.task ({', '.join(fields)}) VALUES ({placeholders}) RETURNING task_id" + sql = ( + f"INSERT INTO timetable.task" + f" ({', '.join(fields)})" + f" VALUES ({placeholders})" + f" RETURNING task_id" + ) status, tid = self.conn.execute_scalar(sql, values) if not status: return status, tid if tid: - status, res = self._upsert_task_params(tid, task.get('parameters', [])) + status, res = self._upsert_task_params( + tid, task.get('parameters', []) + ) if not status: return status, res return True, None def _upsert_task_params(self, task_id, parameters): - if not parameters: - return True, None - def _insert_param(idx, param): if not isinstance(param, dict): param = {'order_id': idx + 1, 'value': str(param)} @@ -511,10 +554,19 @@ def _insert_param(idx, param): val = '' try: json.loads(val) - sql = "INSERT INTO timetable.parameter(task_id, order_id, value) VALUES (%s, %s, %s::jsonb)" + sql = ( + "INSERT INTO timetable.parameter" + "(task_id, order_id, value)" + " VALUES (%s, %s, %s::jsonb)" + ) params = (task_id, order_id, val) except (ValueError, TypeError): - sql = "INSERT INTO timetable.parameter(task_id, order_id, value) VALUES (%s, %s, to_jsonb(%s::text))" + sql = ( + "INSERT INTO timetable.parameter" + "(task_id, order_id, value)" + " VALUES (%s, %s," + " to_jsonb(%s::text))" + ) params = (task_id, order_id, val) return self.conn.execute_void(sql, params) @@ -525,7 +577,10 @@ def _insert_param(idx, param): return status, res if isinstance(parameters, dict): - all_params = parameters.get('changed', []) + parameters.get('added', []) + all_params = ( + parameters.get('changed', []) + + parameters.get('added', []) + ) else: all_params = parameters @@ -603,9 +658,10 @@ def msql(self, gid, sid, chain_id=None): def statistics(self, gid, sid, chain_id): """ statistics - Returns the statistics for a particular database if chain_id is specified, - otherwise it will return statistics for all the databases in that - server. + Returns the statistics for a particular database + if chain_id is specified, + otherwise it will return statistics for all the + databases in that server. """ pref = Preferences.module('browser') rows_threshold = pref.preference( @@ -690,4 +746,5 @@ def run_now(self, gid, sid, chain_id): message=_("Updated the next runtime to now.") ) + ChainView.register_node_view(blueprint) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js index a86783742cb..1fb3b1b75e9 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js @@ -113,9 +113,13 @@ export default class PgtChainSchema extends BaseUISchema { ? tasks.find(t => t.cid === actionObj.value.cid) : tasks[tasks.length - 1]; - if (addedTask && (addedTask.task_order === undefined || addedTask.task_order === null)) { - const lastOrder = tasks.reduce((max, t) => Math.max(max, parseInt(t.task_order, 10) || 0), 0); - addedTask.task_order = lastOrder + 10; + if (addedTask && addedTask.task_id === null) { + if (tasks.length === 1) { + addedTask.task_order = 10; + } else { + const maxOrder = Math.max(...tasks.map(t => parseFloat(t.task_order) || 0)); + addedTask.task_order = maxOrder + 10; + } } } return state; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py index 335fe903f41..587c7158ce2 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -10,6 +10,7 @@ """Implements pgTimeTable Chain Task Node""" import json +import math from functools import wraps from flask import render_template, request, jsonify @@ -24,6 +25,13 @@ from config import PG_DEFAULT_DRIVER +def _fmt_task_order(order): + f = float(order) + if f == int(f): + return str(int(f)) + return f"{f:g}" + + class ChainTaskModule(CollectionNodeModule): """ class ChainTaskModule(CollectionNodeModule) @@ -278,7 +286,8 @@ def nodes(self, gid, sid, chain_id, task_id=None): return gone(errormsg=self.STEP_NOT_FOUND) row = result['rows'][0] - label = f"{int(row['task_order']):04d}: {row['task_name']}" + order_str = _fmt_task_order(row['task_order']) + label = f"{order_str}: {row['task_name']}" node = self.blueprint.generate_browser_node( row['task_id'], row['chain_id'], @@ -290,8 +299,16 @@ def nodes(self, gid, sid, chain_id, task_id=None): ) return make_json_response(node) + max_width = max( + (len(_fmt_task_order(r['task_order'])) + for r in result['rows']), + default=1 + ) for row in result['rows']: - label = f"{int(row['task_order']):04d}: {row['task_name']}" + order_str = _fmt_task_order(row['task_order']).zfill( + max_width + ) + label = f"{order_str}: {row['task_name']}" node = self.blueprint.generate_browser_node( row['task_id'], row['chain_id'], @@ -357,11 +374,18 @@ def create(self, gid, sid, chain_id): if isinstance(params_raw, str): params_raw = json.loads(params_raw) if isinstance(params_raw, dict): - params_raw = params_raw.get('added', []) + params_raw.get('changed', []) + params_raw = ( + params_raw.get('added', []) + + params_raw.get('changed', []) + ) cleaned = [] for idx, param in enumerate(params_raw): if not isinstance(param, dict): - cleaned.append({'order_id': idx + 1, 'value': str(param), '_is_json': False}) + cleaned.append({ + 'order_id': idx + 1, + 'value': str(param), + '_is_json': False + }) else: try: json.loads(param.get('value', '')) @@ -402,7 +426,7 @@ def create(self, gid, sid, chain_id): ) ) row = res['rows'][0] - label = f"{int(row['task_order']):04d}: {row['task_name']}" + label = f"{_fmt_task_order(row['task_order'])}: {row['task_name']}" node = self.blueprint.generate_browser_node( row['task_id'], row['chain_id'], @@ -432,7 +456,10 @@ def update(self, gid, sid, chain_id, task_id): params_raw = json.loads(params_raw) if isinstance(params_raw, dict): - params_list = params_raw.get('changed', []) + params_raw.get('added', []) + params_list = ( + params_raw.get('changed', []) + + params_raw.get('added', []) + ) else: params_list = params_raw @@ -482,7 +509,7 @@ def update(self, gid, sid, chain_id, task_id): ) ) row = res['rows'][0] - label = f"{int(row['task_order']):04d}: {row['task_name']}" + label = f"{_fmt_task_order(row['task_order'])}: {row['task_name']}" node = self.blueprint.generate_browser_node( task_id, chain_id, @@ -541,7 +568,10 @@ def msql(self, gid, sid, chain_id, task_id=None): params_raw = json.loads(params_raw) if isinstance(params_raw, dict): - params_list = params_raw.get('changed', []) + params_raw.get('added', []) + params_list = ( + params_raw.get('changed', []) + + params_raw.get('added', []) + ) else: params_list = params_raw @@ -591,9 +621,10 @@ def msql(self, gid, sid, chain_id, task_id=None): def statistics(self, gid, sid, chain_id, task_id): """ statistics - Returns the statistics for a particular database if chain_id is specified, - otherwise it will return statistics for all the databases in that - server. + Returns the statistics for a particular database + if chain_id is specified, + otherwise it will return statistics for all the + databases in that server. """ pref = Preferences.module('browser') rows_threshold = pref.preference( diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js index c123d92a230..579ca950269 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js @@ -109,8 +109,9 @@ export default class PgtChainTaskSchema extends BaseUISchema { { id: 'task_order', label: gettext('Order'), - type: 'int', - cell: 'int', + type: 'numeric', + cell: 'numeric', + controlProps: { decimals: 2 }, }, { id: 'kind', @@ -143,6 +144,7 @@ For more information, please see the documentation on Date: Wed, 26 Aug 2026 18:14:33 -0400 Subject: [PATCH 16/19] Doc changes - add missing image - refer to the pg_timetable agents as pg_timetable --- docs/en_US/images/pgtimetable_chain_tasks.png | Bin 0 -> 20578 bytes docs/en_US/pgtimetable_chains.rst | 16 ++++++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) create mode 100644 docs/en_US/images/pgtimetable_chain_tasks.png diff --git a/docs/en_US/images/pgtimetable_chain_tasks.png b/docs/en_US/images/pgtimetable_chain_tasks.png new file mode 100644 index 0000000000000000000000000000000000000000..11e520bab07935661b757947921586b7a1286362 GIT binary patch literal 20578 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 Rbr18Mtdyc;-UFof{{zu?AyxnY literal 0 HcmV?d00001 diff --git a/docs/en_US/pgtimetable_chains.rst b/docs/en_US/pgtimetable_chains.rst index 07e2ed2fbd6..0a8310a4da5 100644 --- a/docs/en_US/pgtimetable_chains.rst +++ b/docs/en_US/pgtimetable_chains.rst @@ -5,12 +5,12 @@ `pgTimeTable Chains`:index: *************************************** -pgTimeTable is a PostgreSQL-based scheduling agent that runs and manages what are called `Chains`. +**pg_timetable** is a PostgreSQL-based scheduling agent that runs and manages what are called `Chains`. Each chain is composed of a set of Tasks to be executed in the order they should run. A chain is assigned a single schedule using an extended cron-style format. -This UI has been tested with pgTimeTable 6.3.0 and above. +This UI has been tested with pg_timetable 6.3.0 and above. -A task may be a series of *SQL* statements, a *BUILTIN* pgTimeTable command (such as sending email) +A task may be a series of *SQL* statements, a *BUILTIN* pg_timetable command (such as sending email) or an operating system *PROGRAM* program/batch/shell script. Switches on the *pgTimeTable Chain* dialog (accessed @@ -22,15 +22,15 @@ When you highlight the name of a defined chain in the pgAdmin tree control, the and the *Statistics* tab will display details about the chain's execution. There are also statistics about each task. -pgTimeTable supports the ability to have more than one job agent running at a time. +pg_timetable supports the ability to have more than one job agent running at a time. Job agents can be deployed on various platforms and binaries are readily available to deploy on Linux, Windows, and Mac. For complete installation instructions, configuration options, and command-line usage, please refer to the -`pgTimeTable documentation `_. +`pg_timetable documentation `_. -If you are migrating from pgAgent or another PostgreSQL based scheduler, pgTimeTable provides +If you are migrating from pgAgent or another PostgreSQL based scheduler, pg_timetable provides migration SQL queries to help in that migration. See the `migration tools `_ on GitHub for details. @@ -43,7 +43,7 @@ It will only show if the schema *timetable* and associated tables exist in your Creating a Chain **************** -Note that pgTimeTable is not installed via `CREATE EXTENSION`. +Note that pg_timetable is not installed via `CREATE EXTENSION`. Instead you use a pgTimeTable agent to install the necessary tables. To create or manage a chain, use the pgAdmin tree control to browse to the server on which the timetable schema exists. The tree control will @@ -228,7 +228,7 @@ parameter row: Click the compose icon to close the task definition dialog, then click *Save* on the chain dialog to save all changes. -Refer to `pgTimeTable samples `_ +Refer to `pg_timetable samples `_ for the kind of parameters expected for each type. From 7b2e08bcadda94a482132a7278679ffe316cfaf7 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Wed, 26 Aug 2026 19:08:14 -0400 Subject: [PATCH 17/19] Should fix items 8. Get rid of has_connstr, since pg_timetable 4.0, there has always been a database_connection column in timetable.tasks and pg_timetable v3 the structure is too different to support 9. Get rid of chaintask in update.sql and consolidate sql generation in __init__.py 12. Remove empty utils.py 13. Missing image addressed in prior commit 14. In tests check for lack of privilege on timetable tables 15. Fix tests missing else response calls --- .../browser/server_groups/servers/__init__.py | 1 - .../servers/pg_timetable/__init__.py | 445 ++++++++++++++---- .../servers/pg_timetable/tasks/__init__.py | 19 - .../tasks/tests/test_pgt_task_add.py | 6 +- .../tasks/tests/test_pgt_task_delete.py | 2 + .../tasks/tests/test_pgt_task_get.py | 2 + .../tasks/tests/test_pgt_task_get_nodes.py | 3 +- .../tests/test_pgt_task_get_statistics.py | 2 + .../templates/macros/pgt_chaintask.macros | 18 +- .../templates/pgt_chain/sql/default/tasks.sql | 2 +- .../pgt_chain/sql/default/update.sql | 13 - .../pgt_chaintask/sql/default/create.sql | 2 +- .../pgt_chaintask/sql/default/properties.sql | 2 +- .../pgt_chaintask/sql/default/update.sql | 2 +- .../tests/test_pgt_chain_get_nodes.py | 2 + .../tests/test_pgt_chain_get_statistics.py | 2 + .../pg_timetable/tests/test_pgt_chain_put.py | 2 + .../servers/pg_timetable/tests/utils.py | 18 +- .../servers/pg_timetable/utils.py | 11 - 19 files changed, 389 insertions(+), 165 deletions(-) delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 237f107d9dc..f3c8378fa47 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -391,7 +391,6 @@ def register(self, app, options): from .pgagent import blueprint as module self.submodules.append(module) - # Explicitly pull in your new testme package route from .pg_timetable import blueprint as module self.submodules.append(module) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index 7d77ae96efa..1f142394a7d 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -66,17 +66,6 @@ def backend_supported(self, manager, **kwargs): ) """) if status and res: - status, res = conn.execute_dict(""" -SELECT EXISTS( - SELECT 1 FROM information_schema.columns - WHERE - table_schema='timetable' AND table_name='task' AND - column_name='database_connection' - ) has_connstr""") - - if not status: - return False - manager.db_info['timetable'] = res['rows'][0] return True return False @@ -128,6 +117,13 @@ def register(self, app, options): class ChainView(PGChildNodeView): node_type = blueprint.node_type + _CHAIN_BOOL_KEYS = [ + 'live', 'self_destruct', 'exclusive_execution' + ] + _CHAIN_NULL_IF_EMPTY = [ + 'run_at', 'client_name', 'on_error' + ] + parent_ids = [ {'type': 'int', 'id': 'gid'}, {'type': 'int', 'id': 'sid'} @@ -168,20 +164,6 @@ def wrap(self, *args, **kwargs): # Set the template path for the sql scripts. self.template_path = 'pgt_chain/sql/default' - - if 'timetable' not in self.manager.db_info: - status, res = self.conn.execute_dict(""" -SELECT EXISTS( - SELECT 1 FROM information_schema.columns - WHERE - table_schema='timetable' AND table_name='task' AND - column_name='database_connection' - ) has_connstr""") - - if not status: - return internal_server_error(errormsg=res) - self.manager.db_info['timetable'] = res['rows'][0] - return f(self, *args, **kwargs) return wrap @@ -256,11 +238,7 @@ def properties(self, gid, sid, chain_id=None): status, rset = self.conn.execute_dict( render_template( "/".join([self.template_path, 'tasks.sql']), - chain_id=chain_id, conn=self.conn, - has_connstr=( - self.manager - .db_info['timetable']['has_connstr'] - ) + chain_id=chain_id, conn=self.conn ) ) if not status: @@ -326,8 +304,7 @@ def create(self, gid, sid): status, res = self.conn.execute_scalar( render_template( "/".join([self.template_path, self._CREATE_SQL]), - data=data, conn=self.conn, fetch_id=True, - has_connstr=self.manager.db_info['timetable']['has_connstr'] + data=data, conn=self.conn, fetch_id=True ) ) @@ -386,8 +363,8 @@ def update(self, gid, sid, chain_id): if chain_fields: sets = [] params = [] - bool_keys = ['live', 'self_destruct', 'exclusive_execution'] - null_if_empty = ['run_at', 'client_name', 'on_error'] + bool_keys = self._CHAIN_BOOL_KEYS + null_if_empty = self._CHAIN_NULL_IF_EMPTY for key, val in chain_fields.items(): sets.append(f"{key} = %s") if key in bool_keys: @@ -407,14 +384,18 @@ def update(self, gid, sid, chain_id): self.conn.execute_void('ROLLBACK') return internal_server_error(errormsg=res) - status, res = self._process_ctasks(chain_id, data.get('ctasks', {})) + status, res = self._process_ctasks( + chain_id, data.get('ctasks', {}) + ) if not status: self.conn.execute_void('ROLLBACK') return internal_server_error(errormsg=res) status, res = self.conn.execute_dict( render_template( - "/".join([self.template_path, self._NODES_SQL]), + "/".join([ + self.template_path, self._NODES_SQL + ]), chain_id=chain_id, conn=self.conn ) ) @@ -440,12 +421,265 @@ def update(self, gid, sid, chain_id): ) ) + def _generate_update_sql(self, chain_id, data): + driver = get_driver(PG_DEFAULT_DRIVER) + qt = driver.qtLiteral + + sql_parts = [] + + chain_fields = { + k: data[k] for k in [ + 'chain_name', 'live', 'max_instances', + 'timeout', 'self_destruct', + 'exclusive_execution', 'client_name', + 'on_error', 'run_at' + ] if k in data + } + if chain_fields: + bool_keys = self._CHAIN_BOOL_KEYS + null_if_empty = self._CHAIN_NULL_IF_EMPTY + sets = [] + for key, val in chain_fields.items(): + if key in bool_keys: + v = 'true' if val else 'false' + elif key in null_if_empty and not val: + v = 'NULL' + elif key in ('max_instances', 'timeout'): + if val is not None: + v = f"{val}::integer" + elif key == 'timeout': + v = '0' + else: + v = 'NULL' + elif key == 'chain_name': + v = f"{qt(val, self.conn)}::text" + else: + v = qt(val, self.conn) + sets.append(f" {key} = {v}") + chain_id_q = qt(chain_id, self.conn) + sql_parts.append( + f"UPDATE timetable.chain\n" + f"SET\n" + + ",\n".join(sets) + "\n" + f"WHERE chain_id = {chain_id_q}::integer;" + ) + + ctasks = data.get('ctasks', {}) + if not isinstance(ctasks, dict): + return "\n".join(sql_parts) if sql_parts else "" + + for task in ctasks.get('deleted', []): + tid = ( + task.get('task_id') + if isinstance(task, dict) else task + ) + if tid: + tid_q = qt(tid, self.conn) + cid_q = qt(chain_id, self.conn) + sql_parts.append( + f"DELETE FROM timetable.parameter" + f" WHERE task_id = {tid_q}::integer;" + ) + sql_parts.append( + f"DELETE FROM timetable.task" + f" WHERE task_id = {tid_q}::integer" + f" AND chain_id = {cid_q}::integer;" + ) + + for task in ctasks.get('changed', []): + if not isinstance(task, dict): + continue + tid = task.get('task_id') + if not tid: + continue + tid_q = qt(tid, self.conn) + cid_q = qt(chain_id, self.conn) + sets = [] + if 'kind' in task: + kv = qt(task['kind'], self.conn) + sets.append( + f" kind = {kv}" + f"::timetable.command_kind" + ) + field_map = { + 'task_name': 'text', + 'task_order': 'double precision', + 'command': 'text', + 'ignore_error': None, + 'database_connection': 'text', + } + for field, cast in field_map.items(): + if field not in task: + continue + val = task[field] + if field == 'ignore_error': + v = 'true' if val else 'false' + elif field == 'database_connection': + if val: + v = ( + f"{qt(val, self.conn)}" + f"::text" + ) + else: + v = 'NULL' + else: + v = ( + f"{qt(val, self.conn)}" + f"::{cast}" + ) + sets.append(f" {field} = {v}") + if sets: + sql_parts.append( + f"UPDATE timetable.task\n" + f"SET\n" + + ",\n".join(sets) + "\n" + f"WHERE task_id = {tid_q}::integer" + f" AND chain_id = {cid_q}::integer;" + ) + if 'parameters' in task: + sql_parts.append( + self._generate_params_sql( + tid, task['parameters'] + ) + ) + + for task in ctasks.get('added', []): + if not isinstance(task, dict): + continue + cid_q = qt(chain_id, self.conn) + cols = [ + 'chain_id', 'task_name', 'task_order', + 'command', 'kind', 'ignore_error', + 'database_connection' + ] + vals = [ + f"{cid_q}::integer", + (f"{qt(task.get('task_name', ''), self.conn)}" + f"::text"), + (f"{qt(task.get('task_order', 10), self.conn)}" + f"::double precision"), + (f"{qt(task.get('command', ''), self.conn)}" + f"::text"), + (f"{qt(task.get('kind', 'SQL'), self.conn)}" + f"::timetable.command_kind"), + ] + ie = task.get('ignore_error', False) + vals.append('true' if ie else 'false') + dc = task.get('database_connection', '') + if dc: + vals.append( + f"{qt(dc, self.conn)}::text" + ) + else: + vals.append('NULL') + cols_str = ", ".join(cols) + vals_str = ",\n ".join(vals) + sql_parts.append( + f"INSERT INTO timetable.task(\n" + f" {cols_str}\n" + f") VALUES (\n" + f" {vals_str}\n" + f") RETURNING task_id;" + ) + params = task.get('parameters', []) + if params: + sql_parts.append( + self._generate_added_params_sql( + params + ) + ) + + return "\n".join(sql_parts) if sql_parts else "" + + def _generate_params_sql(self, task_id, parameters): + lines = [] + tid_q = qt(task_id, self.conn) + lines.append( + f"DELETE FROM timetable.parameter" + f" WHERE task_id = {tid_q}::integer;" + ) + if isinstance(parameters, dict): + all_params = ( + parameters.get('changed', []) + + parameters.get('added', []) + ) + else: + all_params = parameters + if not all_params: + return "\n".join(lines) + val_strs = [] + for param in all_params: + if not isinstance(param, dict): + continue + oid = param.get('order_id', 0) + val = param.get('value', '') + if val is None: + val = '' + oid_q = qt(oid, self.conn) + if param.get('_is_json'): + vq = qt(val, self.conn) + val_strs.append( + f"({tid_q}::integer," + f" {oid_q}::integer," + f" {vq}::jsonb)" + ) + else: + vq = qt(val, self.conn) + val_strs.append( + f"({tid_q}::integer," + f" {oid_q}::integer," + f" to_jsonb({vq}::text))" + ) + if val_strs: + lines.append( + "INSERT INTO timetable.parameter" + "(task_id, order_id, value)\n" + "VALUES\n" + + ",\n".join(val_strs) + ";" + ) + return "\n".join(lines) + + def _generate_added_params_sql(self, params): + lines = [] + val_strs = [] + for param in params: + if not isinstance(param, dict): + continue + oid = param.get('order_id', 0) + val = param.get('value', '') + if val is None: + val = '' + oid_q = qt(oid, self.conn) + if param.get('_is_json'): + vq = qt(val, self.conn) + val_strs.append( + f"(tid, {oid_q}::integer," + f" {vq}::jsonb)" + ) + else: + vq = qt(val, self.conn) + val_strs.append( + f"(tid, {oid_q}::integer," + f" to_jsonb({vq}::text))" + ) + if val_strs: + lines.append( + "INSERT INTO timetable.parameter" + "(task_id, order_id, value)\n" + "VALUES\n" + + ",\n".join(val_strs) + ";" + ) + return "\n".join(lines) + def _process_ctasks(self, chain_id, ctasks): if not isinstance(ctasks, dict): return True, None for task in ctasks.get('deleted', []): - tid = task.get('task_id') if isinstance(task, dict) else task + tid = ( + task.get('task_id') + if isinstance(task, dict) else task + ) if tid: status, res = self.conn.execute_void( "DELETE FROM timetable.task" @@ -456,7 +690,6 @@ def _process_ctasks(self, chain_id, ctasks): if not status: return status, res - has_connstr = self.manager.db_info['timetable']['has_connstr'] for task in ctasks.get('changed', []): if not isinstance(task, dict): continue @@ -470,16 +703,18 @@ def _process_ctasks(self, chain_id, ctasks): 'task_order': 'task_order', 'command': 'command', 'ignore_error': 'ignore_error', + 'database_connection': + 'database_connection' } - if has_connstr: - field_map['database_connection'] = 'database_connection' if 'kind' in task: - sets.append("kind = %s::timetable.command_kind") + sets.append( + "kind = %s::timetable.command_kind" + ) params.append(task['kind']) - for frontend_field, db_field in field_map.items(): - if frontend_field in task: + for fe_field, db_field in field_map.items(): + if fe_field in task: sets.append(f"{db_field} = %s") - params.append(task[frontend_field]) + params.append(task[fe_field]) if sets: params.extend([tid, chain_id]) sql = ( @@ -488,11 +723,15 @@ def _process_ctasks(self, chain_id, ctasks): f" WHERE task_id = %s" f" AND chain_id = %s" ) - status, res = self.conn.execute_void(sql, params) + status, res = self.conn.execute_void( + sql, params + ) if not status: return status, res if 'parameters' in task: - status, res = self._upsert_task_params(tid, task['parameters']) + status, res = self._upsert_task_params( + tid, task['parameters'] + ) if not status: return status, res @@ -500,13 +739,15 @@ def _process_ctasks(self, chain_id, ctasks): if not isinstance(task, dict): continue fields = [ - 'chain_id', 'task_name', 'task_order', 'command' + 'chain_id', 'task_name', 'task_order', + 'command', 'database_connection' ] values = [ chain_id, task.get('task_name', ''), task.get('task_order', 10), - task.get('command', '') + task.get('command', ''), + task.get('database_connection', '') ] if 'ignore_error' in task: fields.append('ignore_error') @@ -514,21 +755,18 @@ def _process_ctasks(self, chain_id, ctasks): if 'kind' in task: fields.append('kind') values.append(task['kind']) - if ( - has_connstr and - 'database_connection' in task and - task['database_connection'] - ): - fields.append('database_connection') - values.append(task['database_connection']) - placeholders = ', '.join(['%s'] * len(values)) + placeholders = ', '.join( + ['%s'] * len(values) + ) sql = ( f"INSERT INTO timetable.task" f" ({', '.join(fields)})" f" VALUES ({placeholders})" f" RETURNING task_id" ) - status, tid = self.conn.execute_scalar(sql, values) + status, tid = self.conn.execute_scalar( + sql, values + ) if not status: return status, tid if tid: @@ -543,7 +781,10 @@ def _process_ctasks(self, chain_id, ctasks): def _upsert_task_params(self, task_id, parameters): def _insert_param(idx, param): if not isinstance(param, dict): - param = {'order_id': idx + 1, 'value': str(param)} + param = { + 'order_id': idx + 1, + 'value': str(param) + } param.pop('_t', None) order_id = param.get('order_id') if order_id is None: @@ -559,7 +800,7 @@ def _insert_param(idx, param): "(task_id, order_id, value)" " VALUES (%s, %s, %s::jsonb)" ) - params = (task_id, order_id, val) + p = (task_id, order_id, val) except (ValueError, TypeError): sql = ( "INSERT INTO timetable.parameter" @@ -567,11 +808,13 @@ def _insert_param(idx, param): " VALUES (%s, %s," " to_jsonb(%s::text))" ) - params = (task_id, order_id, val) - return self.conn.execute_void(sql, params) + p = (task_id, order_id, val) + return self.conn.execute_void(sql, p) status, res = self.conn.execute_void( - "DELETE FROM timetable.parameter WHERE task_id = %s", (task_id,) + "DELETE FROM timetable.parameter" + " WHERE task_id = %s", + (task_id,) ) if not status: return status, res @@ -613,6 +856,40 @@ def delete(self, gid, sid, chain_id=None): return make_json_response(success=1) + @staticmethod + def _set_json_markers(ctasks): + """Ensure _is_json markers are set on parameter values.""" + def _mark_params(params): + for param in params: + if not isinstance(param, dict): + continue + val = param.get('value') + if isinstance(val, (dict, list)): + param['value'] = json.dumps(val, indent=2) + param['_is_json'] = True + elif '_is_json' not in param: + param['_is_json'] = False + + if isinstance(ctasks, list): + for task in ctasks: + if isinstance(task, dict): + _mark_params(task.get('parameters', [])) + elif isinstance(ctasks, dict): + for task in ctasks.get('added', []): + if isinstance(task, dict): + _mark_params(task.get('parameters', [])) + for task in ctasks.get('changed', []): + if not isinstance(task, dict): + continue + params = task.get('parameters') + if isinstance(params, dict): + _mark_params( + params.get('changed', []) + + params.get('added', []) + ) + elif isinstance(params, list): + _mark_params(params) + @check_precondition def msql(self, gid, sid, chain_id=None): """ @@ -622,36 +899,30 @@ def msql(self, gid, sid, chain_id=None): for k, v in request.args.items(): try: data[k] = json.loads( - v.decode('utf-8') if hasattr(v, 'decode') else v + v.decode('utf-8') + if hasattr(v, 'decode') else v ) except ValueError: data[k] = v - # Preserve _is_json marker for JSON parameter values in ctasks - if isinstance(data.get('ctasks'), list): - for task in data['ctasks']: - if not isinstance(task, dict): - continue - for param in task.get('parameters', []): - if not isinstance(param, dict): - continue - val = param.get('value') - if isinstance(val, (dict, list)): - param['value'] = json.dumps(val, indent=2) - param['_is_json'] = True - elif '_is_json' not in param: - param['_is_json'] = False + self._set_json_markers(data.get('ctasks')) - return make_json_response( - data=render_template( + if chain_id is not None: + sql = self._generate_update_sql( + chain_id, data + ) + else: + sql = render_template( "/".join([ self.template_path, - self._CREATE_SQL if chain_id is None else self._UPDATE_SQL + self._CREATE_SQL ]), - chain_id=chain_id, data=data, conn=self.conn, fetch_id=False, - has_connstr=self.manager.db_info['timetable']['has_connstr'] - ), - status=200 + data=data, conn=self.conn, + fetch_id=False + ) + + return make_json_response( + data=sql, status=200 ) @check_precondition @@ -707,8 +978,7 @@ def sql(self, gid, sid, chain_id): status, res = self.conn.execute_dict( render_template( "/".join([self.template_path, 'tasks.sql']), - chain_id=chain_id, conn=self.conn, - has_connstr=self.manager.db_info['timetable']['has_connstr'] + chain_id=chain_id, conn=self.conn ) ) if not status: @@ -722,8 +992,7 @@ def sql(self, gid, sid, chain_id): return ajax_response( response=render_template( "/".join([self.template_path, self._CREATE_SQL]), - chain_id=chain_id, data=row, conn=self.conn, fetch_id=False, - has_connstr=self.manager.db_info['timetable']['has_connstr'] + chain_id=chain_id, data=row, conn=self.conn, fetch_id=False ) ) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py index 587c7158ce2..cf3eef25aad 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -208,19 +208,6 @@ def wrap(*args, **kwargs): self.template_path = 'pgt_chaintask/sql/default' - if 'timetable' not in self.manager.db_info: - status, res = self.conn.execute_dict(""" -SELECT EXISTS( - SELECT 1 FROM information_schema.columns - WHERE - table_schema='timetable' AND table_name='task' AND - column_name='database_connection' - ) has_connstr""") - - if not status: - return internal_server_error(errormsg=res) - self.manager.db_info['timetable'] = res['rows'][0] - return f(*args, **kwargs) return wrap @@ -239,7 +226,6 @@ def list(self, gid, sid, chain_id): sql = render_template( "/".join([self.template_path, self._PROPERTIES_SQL]), chain_id=chain_id, - has_connstr=self.manager.db_info['timetable']['has_connstr'], conn=self.conn ) status, res = self.conn.execute_dict(sql) @@ -340,7 +326,6 @@ def properties(self, gid, sid, chain_id, task_id): "/".join([self.template_path, self._PROPERTIES_SQL]), task_id=task_id, chain_id=chain_id, - has_connstr=self.manager.db_info['timetable']['has_connstr'], conn=self.conn ) status, res = self.conn.execute_dict(sql) @@ -399,7 +384,6 @@ def create(self, gid, sid, chain_id): "/".join([self.template_path, self._CREATE_SQL]), chain_id=chain_id, data=data, - has_connstr=self.manager.db_info['timetable']['has_connstr'], conn=self.conn ) @@ -482,7 +466,6 @@ def update(self, gid, sid, chain_id, task_id): chain_id=chain_id, task_id=task_id, data=data, - has_connstr=self.manager.db_info['timetable']['has_connstr'], conn=self.conn ) @@ -594,7 +577,6 @@ def msql(self, gid, sid, chain_id, task_id=None): "/".join([self.template_path, self._CREATE_SQL]), chain_id=chain_id, data=data, - has_connstr=self.manager.db_info['timetable']['has_connstr'], conn=self.conn ) @@ -608,7 +590,6 @@ def msql(self, gid, sid, chain_id, task_id=None): chain_id=chain_id, task_id=task_id, data=data, - has_connstr=self.manager.db_info['timetable']['has_connstr'], conn=self.conn ) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py index 0711de1df89..af34f4c3906 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py @@ -57,9 +57,11 @@ def runTest(self): with patch(self.mock_data["function_name"], side_effect=eval(self.mock_data["return_value"])): response = tasks_utils.api_create(self) + else: + response = tasks_utils.api_create(self) - utils.assert_status_code(self, response) - utils.assert_error_message(self, response) + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) def tearDown(self): """Clean up code""" diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py index bd5c81e4b99..e7dfee6522d 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py @@ -65,6 +65,8 @@ def runTest(self): with patch(self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])]): response = tasks_utils.api_delete(self) + else: + response = tasks_utils.api_delete(self) utils.assert_status_code(self, response) utils.assert_error_message(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py index 9e8a15bc749..888791515ef 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py @@ -59,6 +59,8 @@ def runTest(self): with patch(self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])]): response = tasks_utils.api_get(self) + else: + response = tasks_utils.api_get(self) utils.assert_status_code(self, response) utils.assert_error_message(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py index dfd6cb98b86..45196324eab 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py @@ -59,7 +59,8 @@ def runTest(self): with patch(self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])]): response = tasks_utils.api_get(self) - + else: + response = tasks_utils.api_get(self) utils.assert_status_code(self, response) utils.assert_error_message(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py index 34e4d596e66..aa4bcc88e5f 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py @@ -52,6 +52,8 @@ def runTest(self): with patch(self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])]): response = tasks_utils.api_get_stats(self) + else: + response = tasks_utils.api_get_stats(self) utils.assert_status_code(self, response) utils.assert_error_message(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros index c5342d2397e..eb9574f1e73 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros @@ -1,20 +1,20 @@ -{% macro INSERT(has_connstr, chain_id, data, conn) -%} +{% macro INSERT(chain_id, data, conn) -%} INSERT INTO timetable.task( - chain_id, task_name, task_order, command, kind{% if has_connstr %}, database_connection{% endif %} + chain_id, task_name, task_order, command, kind, database_connection {% if data.ignore_error is defined %}, ignore_error{% endif %} ) VALUES ( {{ chain_id|qtLiteral(conn) }}::integer, {{ data.task_name|qtLiteral(conn) }}::text, {{ data.task_order|qtLiteral(conn) }}::double precision, {{ data.command|qtLiteral(conn) }}::text, - {{ data.kind|qtLiteral(conn) }}::timetable.command_kind - {% if has_connstr %}, {% if data.database_connection %}{{ data.database_connection|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% endif %} + {{ data.kind|qtLiteral(conn) }}::timetable.command_kind, + {% if data.database_connection %}{{ data.database_connection|qtLiteral(conn) }}::text{% else %}NULL{% endif %} {% if data.ignore_error is defined %}, {% if data.ignore_error %}true{% else %}false{% endif %}{% endif %} ) RETURNING task_id {%- endmacro %} -{% macro UPDATE(has_connstr, chain_id, task_id, data, conn) -%} -{% set has_task_fields = 'task_name' in data or 'task_order' in data or 'command' in data or 'kind' in data or 'ignore_error' in data or (has_connstr and 'database_connection' in data) %} +{% macro UPDATE(chain_id, task_id, data, conn) -%} +{% set has_task_fields = 'task_name' in data or 'task_order' in data or 'command' in data or 'kind' in data or 'ignore_error' in data or 'database_connection' in data %} {% if has_task_fields %} {% set comma = joiner(', ') %} UPDATE timetable.task @@ -24,8 +24,8 @@ SET task_order = {{ data.task_order|qtLiteral(conn) }}::double precision{% endif %}{% if 'command' in data %}{{ comma() }} command = {{ data.command|qtLiteral(conn) }}::text{% endif %}{% if 'kind' in data %}{{ comma() }} kind = {{ data.kind|qtLiteral(conn) }}::timetable.command_kind{% endif %}{% if 'ignore_error' in data %}{{ comma() }} - ignore_error = {% if data.ignore_error %}true{% else %}false{% endif %}{% endif %}{% if has_connstr and 'database_connection' in data %}{{ comma() }} - database_connection = {{ data.database_connection|qtLiteral(conn) }}::text{% endif %} + ignore_error = {% if data.ignore_error %}true{% else %}false{% endif %}{% endif %}{% if 'database_connection' in data %}{{ comma() }} + database_connection = {% if data.database_connection %}{{ data.database_connection|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% endif %} WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer AND chain_id = {{ chain_id|qtLiteral(conn) }}::integer; {% endif %} {% if 'parameters' in data %} @@ -45,7 +45,7 @@ DELETE FROM timetable.parameter WHERE task_id = {{ task_id|qtLiteral(conn) }}::i DELETE FROM timetable.task WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer AND chain_id = {{ chain_id|qtLiteral(conn) }}::integer; {%- endmacro %} -{% macro PROPERTIES(has_connstr, chain_id, task_id, conn) -%} +{% macro PROPERTIES(chain_id, task_id, conn) -%} SELECT t.task_id, t.chain_id, t.task_name, t.task_order, t.kind::text AS kind, diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql index b78af6d437e..bdbed31a345 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql @@ -1,2 +1,2 @@ {% import 'macros/pgt_chaintask.macros' as TASK %} -{{ TASK.PROPERTIES(has_connstr, chain_id, task_id, conn) }} +{{ TASK.PROPERTIES(chain_id, task_id, conn) }} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql deleted file mode 100644 index 47aae734ee9..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql +++ /dev/null @@ -1,13 +0,0 @@ -{% if 'chain_name' in data or 'live' in data or 'max_instances' in data or 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %} -UPDATE timetable.chain -SET {% if 'chain_name' in data %}chain_name = {{ data.chain_name|qtLiteral(conn) }}::text{% if 'live' in data or 'max_instances' in data or 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} -{% if 'live' in data %}live = {% if data.live %}true{% else %}false{% endif %}{% if 'max_instances' in data or 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} -{% if 'max_instances' in data %}max_instances = {% if data.max_instances is not none %}{{ data.max_instances|qtLiteral(conn) }}::integer{% else %}NULL{% endif %}{% if 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} -{% if 'timeout' in data %}timeout = {% if data.timeout is not none %}{{ data.timeout|qtLiteral(conn) }}::integer{% else %}0{% endif %}{% if 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} -{% if 'self_destruct' in data %}self_destruct = {% if data.self_destruct %}true{% else %}false{% endif %}{% if 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} -{% if 'exclusive_execution' in data %}exclusive_execution = {% if data.exclusive_execution %}true{% else %}false{% endif %}{% if 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} -{% if 'client_name' in data %}client_name = {% if data.client_name and data.client_name|length > 0 %}{{ data.client_name|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% if 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %} -{% if 'on_error' in data %}on_error = {% if data.on_error and data.on_error|length > 0 %}{{ data.on_error|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% if 'run_at' in data %}, {% endif %}{% endif %} -{% if 'run_at' in data %}run_at = {% if data.run_at and data.run_at|length > 0 %}{{ data.run_at|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% endif %} -WHERE chain_id = {{ chain_id|qtLiteral(conn) }}::integer; -{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql index 76bd63be4f4..c0c3acfc52f 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql @@ -1,6 +1,6 @@ {% import 'macros/pgt_chaintask.macros' as TASK %} WITH tid AS ( - {{ TASK.INSERT(has_connstr, chain_id, data, conn) }} + {{ TASK.INSERT(chain_id, data, conn) }} ) {% if 'parameters' in data and data.parameters|length > 0 %} , ins AS ( diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql index b78af6d437e..bdbed31a345 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql @@ -1,2 +1,2 @@ {% import 'macros/pgt_chaintask.macros' as TASK %} -{{ TASK.PROPERTIES(has_connstr, chain_id, task_id, conn) }} +{{ TASK.PROPERTIES(chain_id, task_id, conn) }} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql index adbc82fc3e4..644ea67b69e 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql @@ -1,2 +1,2 @@ {% import 'macros/pgt_chaintask.macros' as TASK %} -{{ TASK.UPDATE(has_connstr, chain_id, task_id, data, conn) }} +{{ TASK.UPDATE(chain_id, task_id, data, conn) }} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py index 972eacebf99..690c29eda75 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py @@ -58,6 +58,8 @@ def runTest(self): self.chain_id = self.data["chain_id"] response = pgt_utils.api_get(self) self.chain_id = existing_chain_id + else: + response = pgt_utils.api_get(self) utils.assert_status_code(self, response) utils.assert_error_message(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py index 4ccbff0aed9..4a79c855689 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py @@ -46,6 +46,8 @@ def runTest(self): with patch(self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])]): response = pgt_utils.api_get_stats(self) + else: + response = pgt_utils.api_get_stats(self) utils.assert_status_code(self, response) utils.assert_error_message(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py index 40d052d9093..12bca2bc71d 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py @@ -46,6 +46,8 @@ def runTest(self): with patch(self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])]): response = pgt_utils.api_put(self) + else: + response = pgt_utils.api_put(self) utils.assert_status_code(self, response) utils.assert_error_message(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py index ffe85df06ba..3621b3ccd50 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py @@ -130,27 +130,11 @@ def is_pgtimetable_installed_on_server(self): """ pg_cursor.execute(SQL) result = pg_cursor.fetchone() - if result is None: + if result is None or not result[0]: connection.close() message = "Make sure pgTimetable is installed properly." return False, message - SQL = """ - SELECT EXISTS( - SELECT 1 FROM information_schema.columns - WHERE - table_schema='timetable' AND table_name='task' AND - column_name='database_connection' - ) has_connstr - """ - pg_cursor.execute(SQL) - result = pg_cursor.fetchone() - if result is None: - connection.close() - message = "Make sure pgTimetable is installed properly." - return False, message - - connection.close() return True, None except Exception: traceback.print_exc(file=sys.stderr) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py deleted file mode 100644 index 228d46b3203..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py +++ /dev/null @@ -1,11 +0,0 @@ -########################################################################## -# -# pgAdmin 4 - PostgreSQL Tools -# -# Copyright (C) 2013 - 2026, The pgAdmin Development Team -# This software is released under the PostgreSQL Licence -# -########################################################################## - -"""pgtimetable helper utilities""" -from flask import render_template From 91223e8467693d4c846a7d08ebcf1c660fc78f0a Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Fri, 28 Aug 2026 08:11:35 -0400 Subject: [PATCH 18/19] SUGGESTED CHANGES - Remove getNodeListByName since we don't have a database dropdown May add back later in a later phase - Get rid of redundant paramSchema - pgt_chain.js change returncode to status to match with the name in sql - Get rid of redundant el.last_run in chain properties.sql - run_now should state it is using notification instead of updating tables. Also add in notice which client was notified and if no client could be notified. Include tests to test new messaging. - Fix statistics docstrings copy-paste error. Should be talking about chains - Fix int/bigint datatype mismatch for chain_id and task_id BUG FIXES - Chain properties tab is not showing the true last run, should use finished for sorting and also account for running jobs. Also expose status. - Fix W503 python style issues --- .../servers/pg_timetable/__init__.py | 38 +++++---- .../pg_timetable/static/js/pgt_chain.js | 12 ++- .../pg_timetable/static/js/pgt_chain.ui.js | 3 + .../servers/pg_timetable/tasks/__init__.py | 7 +- .../tasks/static/js/pgt_chaintask.js | 4 +- .../tasks/static/js/pgt_chaintask.ui.js | 82 ++++++------------ .../pgt_chain/sql/default/create.sql | 4 +- .../pgt_chain/sql/default/properties.sql | 9 +- .../pgt_chain/sql/default/run_now.sql | 45 +++++----- .../tests/pgt_timetable_test_data.json | 16 ++++ .../tests/test_pgt_chain_run_now.py | 85 +++++++++++++++++++ 11 files changed, 197 insertions(+), 108 deletions(-) create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_run_now.py diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index 1f142394a7d..86fb670aa9e 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -21,7 +21,7 @@ from pgadmin.browser.utils import PGChildNodeView from pgadmin.browser.server_groups import servers from pgadmin.utils.ajax import make_json_response, internal_server_error, \ - make_response as ajax_response, gone, success_return + make_response as ajax_response, gone from pgadmin.utils.driver import get_driver from pgadmin.utils.preferences import Preferences @@ -459,8 +459,8 @@ def _generate_update_sql(self, chain_id, data): chain_id_q = qt(chain_id, self.conn) sql_parts.append( f"UPDATE timetable.chain\n" - f"SET\n" - + ",\n".join(sets) + "\n" + f"SET\n" + + ",\n".join(sets) + "\n" f"WHERE chain_id = {chain_id_q}::integer;" ) @@ -531,8 +531,8 @@ def _generate_update_sql(self, chain_id, data): if sets: sql_parts.append( f"UPDATE timetable.task\n" - f"SET\n" - + ",\n".join(sets) + "\n" + f"SET\n" + + ",\n".join(sets) + "\n" f"WHERE task_id = {tid_q}::integer" f" AND chain_id = {cid_q}::integer;" ) @@ -634,8 +634,8 @@ def _generate_params_sql(self, task_id, parameters): lines.append( "INSERT INTO timetable.parameter" "(task_id, order_id, value)\n" - "VALUES\n" - + ",\n".join(val_strs) + ";" + "VALUES\n" + + ",\n".join(val_strs) + ";" ) return "\n".join(lines) @@ -666,8 +666,8 @@ def _generate_added_params_sql(self, params): lines.append( "INSERT INTO timetable.parameter" "(task_id, order_id, value)\n" - "VALUES\n" - + ",\n".join(val_strs) + ";" + "VALUES\n" + + ",\n".join(val_strs) + ";" ) return "\n".join(lines) @@ -929,10 +929,9 @@ def msql(self, gid, sid, chain_id=None): def statistics(self, gid, sid, chain_id): """ statistics - Returns the statistics for a particular database - if chain_id is specified, - otherwise it will return statistics for all the - databases in that server. + Returns the recent execution details (run, status, start time, + end time, duration and task) for the specified chain, up to the + configured rows threshold. """ pref = Preferences.module('browser') rows_threshold = pref.preference( @@ -1002,7 +1001,7 @@ def run_now(self, gid, sid, chain_id): This function will set the next run to now, to inform pgTimeTable to run the chain now. """ - status, res = self.conn.execute_void( + status, res = self.conn.execute_dict( render_template( "/".join([self.template_path, 'run_now.sql']), chain_id=chain_id, conn=self.conn @@ -1011,8 +1010,15 @@ def run_now(self, gid, sid, chain_id): if not status: return internal_server_error(errormsg=res) - return success_return( - message=_("Updated the next runtime to now.") + rows = (res or {}).get('rows') or [] + if not rows: + return gone(errormsg=_("Could not find the requested chain.")) + + notice = rows[0]['notice'] + notification = bool(rows[0].get('notification')) + + return make_json_response( + success=1, info=notice, data={'notification': notification} ) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js index 8cc44e7a06f..ad3ca586665 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js @@ -21,7 +21,7 @@ define('pgadmin.node.pgt_chain', [ node: 'pgt_chain', label: gettext('pgt_chains'), type: 'coll-pgt_chain', - columns: ['chain_id', 'chain_name', 'live', 'returncode', 'run_at'], + columns: ['chain_id', 'chain_name', 'live', 'status', 'run_at'], hasStatistics: false, canDrop: true, canDropCascade: false, @@ -51,8 +51,8 @@ define('pgadmin.node.pgt_chain', [ this.initialized = true; }, - getSchema: function (treeNodeInfo, itemNodeData) { - return new PgtChainSchema({}, () => getNodePgtChainTaskSchema(treeNodeInfo, itemNodeData)); + getSchema: function () { + return new PgtChainSchema({}, () => getNodePgtChainTaskSchema()); }, run_pgt_chain_now: function (args) { @@ -65,7 +65,11 @@ define('pgadmin.node.pgt_chain', [ getApiInstance() .put(obj.generate_url(i, 'run_now', d, true)) .then(({ data: res }) => { - pgAdmin.Browser.notifier.success(res.info); + if (res.data && res.data.notification) { + pgAdmin.Browser.notifier.success(res.info); + } else { + pgAdmin.Browser.notifier.warning(res.info); + } t.unload(i); }) .catch(function (error) { diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js index 1fb3b1b75e9..8608e5fbd40 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js @@ -83,6 +83,9 @@ export default class PgtChainSchema extends BaseUISchema { -- │ └────────── hour (0 - 23) -- └──────────── minute (0 - 59)`), helpMessageMode: ['create', 'edit'], + },{ + id: 'status', label: gettext('Status'), type: 'text', + mode: ['properties'], },{ id: 'last_run', label: gettext('Last run'), type: 'text', mode: ['properties'], diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py index cf3eef25aad..88ae0238533 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -602,10 +602,9 @@ def msql(self, gid, sid, chain_id, task_id=None): def statistics(self, gid, sid, chain_id, task_id): """ statistics - Returns the statistics for a particular database - if chain_id is specified, - otherwise it will return statistics for all the - databases in that server. + Returns the recent execution details (run, status, start time, + end time, duration and output) for the specified task of the + given chain, up to the configured rows threshold. """ pref = Preferences.module('browser') rows_threshold = pref.preference( diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js index dbe6cc6e72b..b5c5e8a80f1 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js @@ -69,8 +69,8 @@ define('pgadmin.node.pgt_chaintask', [ }]); }, - getSchema: function(treeNodeInfo, itemNodeData) { - return getNodePgtChainTaskSchema(treeNodeInfo, itemNodeData); + getSchema: function() { + return getNodePgtChainTaskSchema(); }, }); diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js index 579ca950269..f8ea9fda180 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js @@ -10,52 +10,14 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; import { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView'; -import { getNodeListByName } from '../../../../../../static/js/node_ajax'; import { isEmptyString } from 'sources/validators'; -export function getNodePgtChainTaskSchema(treeNodeInfo, itemNodeData) { - const paramSchema = new (class extends BaseUISchema { - constructor() { - super({ order_id: null, value: '' }); - } - get baseFields() { - return [ - { id: '_t', type: 'boolean' }, - { id: 'order_id', label: gettext('Order'), type: 'int', noEmpty: true, cell: 'int', width: 20 }, - { id: 'value', label: gettext('Value'), type: 'multiline', cell: 'text' }, - ]; - } - validate(state, setError) { - if (!state.order_id || state.order_id < 1) { - setError('order_id', gettext('Order must be a positive integer.')); - return true; - } - setError('order_id', null); - if (isEmptyString(state.value)) { - setError('value', gettext('Please enter a parameter value.')); - return true; - } - setError('value', null); - } - })(); - - return new PgtChainTaskSchema( - { - databases: () => - getNodeListByName('database', treeNodeInfo, itemNodeData, { - cacheLevel: 'database', - cacheNode: 'database', - }), - paramSchema, - }, - { - jstdbname: treeNodeInfo['server']['db'], - } - ); +export function getNodePgtChainTaskSchema() { + return new PgtChainTaskSchema(); } export default class PgtChainTaskSchema extends BaseUISchema { - constructor(fieldOptions = {}, initValues = {}) { + constructor(initValues = {}) { super({ task_id: null, chain_id: null, @@ -69,22 +31,30 @@ export default class PgtChainTaskSchema extends BaseUISchema { ...initValues, }); - this.fieldOptions = { - databases: [], - paramSchema: new (class extends BaseUISchema { - constructor() { - super({ order_id: null, value: '' }); + this.paramSchema = new (class extends BaseUISchema { + constructor() { + super({ order_id: null, value: '' }); + } + get baseFields() { + return [ + { id: '_t', type: 'boolean' }, + { id: 'order_id', label: gettext('Order'), type: 'int', noEmpty: true, cell: 'int', width: 20 }, + { id: 'value', label: gettext('Value'), type: 'multiline', cell: 'text' }, + ]; + } + validate(state, setError) { + if (!state.order_id || state.order_id < 1) { + setError('order_id', gettext('Order must be a positive integer.')); + return true; } - get baseFields() { - return [ - { id: '_t', type: 'boolean' }, - { id: 'order_id', label: gettext('Order'), type: 'int', noEmpty: true, cell: 'int', width: 20 }, - { id: 'value', label: gettext('Value'), type: 'multiline', cell: 'text' }, - ]; + setError('order_id', null); + if (isEmptyString(state.value)) { + setError('value', gettext('Please enter a parameter value.')); + return true; } - })(), - ...fieldOptions, - }; + setError('value', null); + } + })(); } get idAttribute() { @@ -166,7 +136,7 @@ For more information, please see the documentation on '' THEN + timetable.notify_chain_start(chain_id, client_name) + ELSE NULL + END AS notification_acted, + (a.client_name > '') AS notification +FROM a; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json index 00f243eb8f9..67f518cf626 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json @@ -443,6 +443,22 @@ "error_msg": null, "test_result_data": {} } + }, + { + "name": "Run chain now: Designated client not active.", + "url": "/browser/pgt_chain/run_now/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": { + "designated_client": "ghost_agent" + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } } ] } diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_run_now.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_run_now.py new file mode 100644 index 00000000000..f08af0da73e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_run_now.py @@ -0,0 +1,85 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from unittest.mock import patch + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from . import utils as pgt_utils + + +class PgtChainRunNowTestCase(BaseTestGenerator): + """This class will test the run now pgTimetable chain API""" + scenarios = utils.generate_scenarios("pgt_chain_run_now", + pgt_utils.test_cases) + + def setUp(self): + super().setUp() + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self) + if not flag: + self.skipTest(msg) + + self.data = self.test_data + + name = "test_chain_run_now%s" % str(uuid.uuid4())[1:8] + self.chain_id = pgt_utils.create_pgtimetable_chain(self, name) + + designated = self.test_data.get("designated_client") + if designated: + self.set_chain_client_name(designated) + + def set_chain_client_name(self, client_name): + connection = None + try: + connection = utils.get_db_connection( + self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode'] + ) + old_isolation_level = connection.isolation_level + utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + pg_cursor.execute( + "UPDATE timetable.chain SET client_name = '%s'::text " + "WHERE chain_id = '%s'::integer;" + % (client_name, self.chain_id) + ) + utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + except Exception: + import traceback + traceback.print_exc() + finally: + if connection: + connection.close() + + def runTest(self): + """This function will notify pgTimetable to run the chain now""" + + response = pgt_utils.api_run_now(self) + + utils.assert_status_code(self, response) + + res = response.json + if self.is_positive_test: + self.assertTrue(res["data"]["notification"]) + else: + self.assertFalse(res["data"]["notification"]) + self.assertIn("is not active", res["info"]) + + def tearDown(self): + """Clean up code""" + pgt_utils.delete_pgtimetable_chain(self) From fa5af8c9bc86199e482009784d864cc2abb56deb Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Fri, 28 Aug 2026 12:25:02 -0400 Subject: [PATCH 19/19] PDF Build fix and note about empty schedules - Fix PDF building by sticking to just ASCII only characters to describe the cron. - Change Note about null schedule, it is actually treated as run every minute to my surprise --- docs/en_US/pgtimetable_chains.rst | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/en_US/pgtimetable_chains.rst b/docs/en_US/pgtimetable_chains.rst index 0a8310a4da5..2dfd08b40f3 100644 --- a/docs/en_US/pgtimetable_chains.rst +++ b/docs/en_US/pgtimetable_chains.rst @@ -105,16 +105,15 @@ chain: .. code-block:: text * * * * * - ┬ ┬ ┬ ┬ ┬ - │ │ │ │ │ - │ │ │ │ └──── day of the week (0 - 7) (0 and 7 = Sunday) - │ │ │ └────── month (1 - 12) - │ │ └──────── day of the month (1 - 31) - │ └────────── hour (0 - 23) - └──────────── minute (0 - 59) - - Leave the field blank to disable automatic scheduling. The chain can still - be started manually using the *Run now* option. + | | | | | + | | | | +---- day of the week (0 - 7) (0 and 7 = Sunday) + | | | +------ month (1 - 12) + | | +-------- day of the month (1 - 31) + | +---------- hour (0 - 23) + +------------ minute (0 - 59) + + If you leave the field blank it runs every minute + (the effective schedule is `* * * * *`). In addition to cron syntax, pgTimeTable supports additional special case formats. Namely `@after`, `@every`, `@reboot`.