Skip to content

Commit ee2a16d

Browse files
authored
SSH SQL Query utility (move-coop#1102)
Added SSH util function we can connect through SSH and query through, added a test for that. Added documentation about using SQL Mirror support for ActionNetwork which lets us query our data/connect to our data we have in ActionNetwork (all tables read only access).
1 parent 686f722 commit ee2a16d

File tree

8 files changed

+184
-5
lines changed

8 files changed

+184
-5
lines changed

docs/action_network.rst

+30-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ Overview
66
********
77

88
`Action Network <https://actionnetwork.org/>`_ is an online tool for storing information
9-
and organizing volunteers and donors. It is used primarily for digital organizing and event mangement. For more information, see `Action Network developer docs <https://actionnetwork.org/docs>`_
9+
and organizing volunteers and donors. It is used primarily for digital organizing and event mangement. For more information, see `Action Network developer docs <https://actionnetwork.org/docs>`_, `SQL Mirror developer docs <https://actionnetwork.org/mirroring/docs>`_
10+
1011

1112
.. note::
1213
Authentication
@@ -99,6 +100,34 @@ You can then call various endpoints:
99100
# Get a specific wrapper
100101
specific_wrapper = an.get_wrapper('wrapper_id')
101102
103+
***********
104+
SQL Mirror
105+
***********
106+
107+
.. code-block:: python
108+
109+
from parsons.utilities.ssh_utilities import query_through_ssh
110+
111+
# Define SSH and database parameters
112+
ssh_host = 'ssh.example.com'
113+
ssh_port = 22
114+
ssh_username = 'user'
115+
ssh_password = 'pass'
116+
db_host = 'db.example.com'
117+
db_port = 5432
118+
db_name = 'testdb'
119+
db_username = 'dbuser'
120+
db_password = 'dbpass'
121+
query = 'SELECT * FROM table'
122+
123+
# Use the function to query through SSH
124+
result = query_through_ssh(
125+
ssh_host, ssh_port, ssh_username, ssh_password,
126+
db_host, db_port, db_name, db_username, db_password, query
127+
)
128+
129+
# Output the result
130+
print(result)
102131
103132
***
104133
API

parsons/action_network/action_network.py

-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import re
44
import warnings
55
from typing import Dict, List, Union
6-
76
from parsons import Table
87
from parsons.utilities import check_env
98
from parsons.utilities.api_connector import APIConnector

parsons/utilities/ssh_utilities.py

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import logging
2+
import sshtunnel
3+
import psycopg2
4+
5+
6+
def query_through_ssh(
7+
ssh_host,
8+
ssh_port,
9+
ssh_username,
10+
ssh_password,
11+
db_host,
12+
db_port,
13+
db_name,
14+
db_username,
15+
db_password,
16+
query,
17+
):
18+
"""
19+
`Args:`
20+
ssh_host:
21+
The host for the SSH connection
22+
ssh_port:
23+
The port for the SSH connection
24+
ssh_username:
25+
The username for the SSH connection
26+
ssh_password:
27+
The password for the SSH connection
28+
db_host:
29+
The host for the db connection
30+
db_port:
31+
The port for the db connection
32+
db_name:
33+
The name of the db database
34+
db_username:
35+
The username for the db database
36+
db_password:
37+
The password for the db database
38+
query:
39+
The SQL query to execute
40+
41+
`Returns:`
42+
A list of records resulting from the query or None if something went wrong
43+
"""
44+
output = None
45+
server = None
46+
con = None
47+
try:
48+
server = sshtunnel.SSHTunnelForwarder(
49+
(ssh_host, int(ssh_port)),
50+
ssh_username=ssh_username,
51+
ssh_password=ssh_password,
52+
remote_bind_address=(db_host, int(db_port)),
53+
)
54+
server.start()
55+
logging.info("SSH tunnel established successfully.")
56+
57+
con = psycopg2.connect(
58+
host="localhost",
59+
port=server.local_bind_port,
60+
database=db_name,
61+
user=db_username,
62+
password=db_password,
63+
)
64+
logging.info("Database connection established successfully.")
65+
66+
cursor = con.cursor()
67+
cursor.execute(query)
68+
records = cursor.fetchall()
69+
output = records
70+
logging.info(f"Query executed successfully: {records}")
71+
except Exception as e:
72+
logging.error(f"Error during query execution: {e}")
73+
raise e
74+
finally:
75+
if con:
76+
con.close()
77+
logging.info("Database connection closed.")
78+
if server:
79+
server.stop()
80+
logging.info("SSH tunnel closed.")
81+
return output

requirements-dev.txt

+1
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ pytest-mock==3.12.0
88
pytest==8.1.1
99
requests-mock==1.11.0
1010
testfixtures==8.1.0
11+

requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,5 @@ xmltodict==0.11.0
4646
jinja2>=3.0.2
4747
selenium==3.141.0
4848
us==3.1.1
49+
sshtunnel==0.4.0
50+

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def main():
6363
"smtp": ["validate-email"],
6464
"targetsmart": ["xmltodict"],
6565
"twilio": ["twilio"],
66+
"ssh": ["sshtunnel", "psycopg2-binary>=2.9.9", "sqlalchemy >= 1.4.22, != 1.4.33, < 2.0.0"]
6667
}
6768
extras_require["all"] = sorted({lib for libs in extras_require.values() for lib in libs})
6869
else:

test/test_action_network/test_action_network.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import unittest
22
import requests_mock
33
import json
4-
from parsons import Table, ActionNetwork
4+
from parsons import Table
5+
from parsons.action_network import ActionNetwork
56
from test.utils import assert_matching_tables
67

78

@@ -4293,8 +4294,7 @@ def test_get_wrapper(self, m):
42934294
self.fake_wrapper,
42944295
)
42954296

4296-
# Unique ID Lists
4297-
4297+
# Unique ID Lists
42984298
@requests_mock.Mocker()
42994299
def test_get_unique_id_lists(self, m):
43004300
m.get(
+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import unittest
2+
from unittest.mock import patch, MagicMock
3+
from parsons.utilities.ssh_utilities import query_through_ssh
4+
5+
6+
class TestSSHTunnelUtility(unittest.TestCase):
7+
@patch("parsons.utilities.ssh_utilities.sshtunnel.SSHTunnelForwarder")
8+
@patch("parsons.utilities.ssh_utilities.psycopg2.connect")
9+
def test_query_through_ssh(self, mock_connect, mock_tunnel):
10+
# Setup mock for SSHTunnelForwarder
11+
mock_tunnel_instance = MagicMock()
12+
mock_tunnel.return_value = mock_tunnel_instance
13+
mock_tunnel_instance.start.return_value = None
14+
mock_tunnel_instance.stop.return_value = None
15+
mock_tunnel_instance.local_bind_port = 12345
16+
17+
# Setup mock for psycopg2.connect
18+
mock_conn_instance = MagicMock()
19+
mock_connect.return_value = mock_conn_instance
20+
mock_cursor = MagicMock()
21+
mock_conn_instance.cursor.return_value = mock_cursor
22+
mock_cursor.fetchall.return_value = [("row1",), ("row2",)]
23+
24+
# Define the parameters for the test
25+
ssh_host = "ssh.example.com"
26+
ssh_port = 22
27+
ssh_username = "user"
28+
ssh_password = "pass"
29+
db_host = "db.example.com"
30+
db_port = 5432
31+
db_name = "testdb"
32+
db_username = "dbuser"
33+
db_password = "dbpass"
34+
query = "SELECT * FROM table"
35+
36+
# Execute the function under test
37+
result = query_through_ssh(
38+
ssh_host,
39+
ssh_port,
40+
ssh_username,
41+
ssh_password,
42+
db_host,
43+
db_port,
44+
db_name,
45+
db_username,
46+
db_password,
47+
query,
48+
)
49+
50+
# Assert that the result is as expected
51+
self.assertEqual(result, [("row1",), ("row2",)])
52+
mock_tunnel.assert_called_once_with(
53+
(ssh_host, ssh_port),
54+
ssh_username=ssh_username,
55+
ssh_password=ssh_password,
56+
remote_bind_address=(db_host, db_port),
57+
)
58+
mock_connect.assert_called_once_with(
59+
host="localhost", port=12345, database=db_name, user=db_username, password=db_password
60+
)
61+
mock_cursor.execute.assert_called_once_with(query)
62+
mock_cursor.fetchall.assert_called_once()
63+
64+
65+
if __name__ == "__main__":
66+
unittest.main()

0 commit comments

Comments
 (0)