memberjojo 2.2__py3-none-any.whl → 2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memberjojo/__init__.py +2 -0
- memberjojo/_version.py +2 -2
- memberjojo/download.py +116 -0
- memberjojo/mojo_common.py +171 -82
- memberjojo/mojo_loader.py +110 -61
- memberjojo/mojo_member.py +109 -33
- memberjojo/mojo_transaction.py +9 -8
- memberjojo/sql_query.py +12 -0
- memberjojo/url.py +82 -0
- {memberjojo-2.2.dist-info → memberjojo-2.3.dist-info}/METADATA +18 -7
- memberjojo-2.3.dist-info/RECORD +13 -0
- memberjojo-2.2.dist-info/RECORD +0 -10
- {memberjojo-2.2.dist-info → memberjojo-2.3.dist-info}/WHEEL +0 -0
- {memberjojo-2.2.dist-info → memberjojo-2.3.dist-info}/licenses/LICENSE +0 -0
memberjojo/__init__.py
CHANGED
memberjojo/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '2.
|
|
32
|
-
__version_tuple__ = version_tuple = (2,
|
|
31
|
+
__version__ = version = '2.3'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 3)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
memberjojo/download.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A class for downloading from membermojo
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import re
|
|
7
|
+
from http.cookiejar import MozillaCookieJar
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .url import URL
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Download:
|
|
15
|
+
"""A class for managing Membermojo downloads"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, shortname: str, cookie_jar: MozillaCookieJar):
|
|
18
|
+
"""
|
|
19
|
+
Initialise the class
|
|
20
|
+
|
|
21
|
+
:param shortname: the membermojo shortname
|
|
22
|
+
:param cookie_jar: a MozillaCookieJar with the session cookie, or empty to get one
|
|
23
|
+
"""
|
|
24
|
+
self.url = URL(shortname)
|
|
25
|
+
self.cookie_jar = cookie_jar
|
|
26
|
+
self.session = requests.Session()
|
|
27
|
+
self.session.cookies = self.cookie_jar
|
|
28
|
+
|
|
29
|
+
def fill_login(self):
|
|
30
|
+
"""
|
|
31
|
+
Prompt for email and password to get login data
|
|
32
|
+
|
|
33
|
+
:return: a dict of the login data
|
|
34
|
+
"""
|
|
35
|
+
email = input("📧 Enter your Membermojo email: ").strip()
|
|
36
|
+
password = getpass.getpass("🔐 Enter your password: ").strip()
|
|
37
|
+
|
|
38
|
+
# Submit login form (this triggers verification email if needed)
|
|
39
|
+
return {"email": email, "password": password}
|
|
40
|
+
|
|
41
|
+
def mojo_login(self, login_data: dict, email_verify: bool = False):
|
|
42
|
+
"""
|
|
43
|
+
Login to membermojo, cookie jar should be saved afterwards with updated cookie
|
|
44
|
+
|
|
45
|
+
:param login_data: a dict containing email and password for requests
|
|
46
|
+
:param email_verify: if True membermojo email verification will be triggered
|
|
47
|
+
if login fails, or no cookie found to create a new session cookie
|
|
48
|
+
|
|
49
|
+
:raises ValueError: If authentication fails and email_verify is False
|
|
50
|
+
"""
|
|
51
|
+
if not self.session.cookies:
|
|
52
|
+
if email_verify:
|
|
53
|
+
print("🍪 No cookies saved, triggering email verification.")
|
|
54
|
+
self.trigger_email(login_data)
|
|
55
|
+
else:
|
|
56
|
+
raise ValueError("⚠️ No cookies found — email verification required.")
|
|
57
|
+
self.session.post(self.url.login, data=login_data)
|
|
58
|
+
|
|
59
|
+
# Attempt to access a protected page to verify login worked
|
|
60
|
+
print(f"Verifying login with: {self.url.test}")
|
|
61
|
+
verify_response = self.session.get(self.url.test)
|
|
62
|
+
if "<mm2-loginpage" in verify_response.text:
|
|
63
|
+
if email_verify:
|
|
64
|
+
print("📧 Authentication failed, triggering email verification")
|
|
65
|
+
self.trigger_email(login_data)
|
|
66
|
+
else:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"⚠️ Authentication Failed — email verification required."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def trigger_email(self, login_data: dict):
|
|
72
|
+
"""
|
|
73
|
+
Triggers a login verification email, prompts the user for the verification URL,
|
|
74
|
+
and then submits it to complete the login process.
|
|
75
|
+
|
|
76
|
+
:param login_data: A dictionary containing login credentials (e.g., email)
|
|
77
|
+
|
|
78
|
+
:raises: ValueError: If a CSRF token cannot be found or if the login form submission fails
|
|
79
|
+
"""
|
|
80
|
+
self.session.cookies.clear()
|
|
81
|
+
response = self.session.post(self.url.login, data=login_data)
|
|
82
|
+
|
|
83
|
+
if "check your email" in response.text.lower() or response.ok:
|
|
84
|
+
print("✅ Login submitted — check your inbox for a verification link.")
|
|
85
|
+
|
|
86
|
+
# Get CSRF token from homepage
|
|
87
|
+
homepage = self.session.get(self.url.base_url)
|
|
88
|
+
match = re.search(r'"csrf_token":"([^"]+)"', homepage.text)
|
|
89
|
+
if not match:
|
|
90
|
+
raise ValueError("❌ Could not find CSRF token.")
|
|
91
|
+
|
|
92
|
+
csrf_token = match.group(1)
|
|
93
|
+
print(f"✅ CSRF token: {csrf_token}")
|
|
94
|
+
|
|
95
|
+
# Ask user for the verification link
|
|
96
|
+
verification_url = input(
|
|
97
|
+
"🔗 Paste the verification URL from the email: "
|
|
98
|
+
).strip()
|
|
99
|
+
|
|
100
|
+
# Submit the verification request
|
|
101
|
+
verify_response = self.session.post(
|
|
102
|
+
verification_url, data={"csrf_token": csrf_token}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Output
|
|
106
|
+
if verify_response.ok:
|
|
107
|
+
print("✅ Verification successful. You're now logged in.")
|
|
108
|
+
else:
|
|
109
|
+
print("⚠️ Verification may have failed.")
|
|
110
|
+
verify_html = Path("verify.html")
|
|
111
|
+
with verify_html.open("w", encoding="UTF-8") as f:
|
|
112
|
+
f.write(verify_response.text)
|
|
113
|
+
|
|
114
|
+
else:
|
|
115
|
+
print(response.text)
|
|
116
|
+
raise ValueError("❌ Failed to submit login form.")
|
memberjojo/mojo_common.py
CHANGED
|
@@ -1,53 +1,69 @@
|
|
|
1
1
|
"""
|
|
2
2
|
MojoSkel base class
|
|
3
3
|
|
|
4
|
-
This module provides a common base class (`MojoSkel`) for other `memberjojo` modules
|
|
5
|
-
It includes helper methods for working with SQLite databases
|
|
4
|
+
This module provides a common base class (`MojoSkel`) for other `memberjojo` modules
|
|
5
|
+
It includes helper methods for working with SQLite databases
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
# pylint: disable=no-member
|
|
9
|
-
|
|
10
8
|
from dataclasses import make_dataclass
|
|
11
9
|
from decimal import Decimal, InvalidOperation
|
|
12
10
|
from pathlib import Path
|
|
13
|
-
from
|
|
11
|
+
from pprint import pprint
|
|
12
|
+
from typing import Any, Iterator, List, Type, Union
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from sqlcipher3 import dbapi2 as sqlite3
|
|
18
|
+
|
|
19
|
+
HAS_SQLCIPHER = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
import sqlite3 # stdlib
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
HAS_SQLCIPHER = False
|
|
16
24
|
|
|
17
25
|
from . import mojo_loader
|
|
26
|
+
from .sql_query import Like
|
|
18
27
|
|
|
19
28
|
|
|
20
29
|
class MojoSkel:
|
|
21
30
|
"""
|
|
22
31
|
Establishes a connection to a SQLite database and provides helper methods
|
|
23
|
-
for querying tables
|
|
32
|
+
for querying tables
|
|
24
33
|
"""
|
|
25
34
|
|
|
26
35
|
def __init__(self, db_path: str, db_key: str, table_name: str):
|
|
27
36
|
"""
|
|
28
|
-
Initialize the MojoSkel class
|
|
37
|
+
Initialize the MojoSkel class
|
|
29
38
|
|
|
30
39
|
Connects to the SQLite database and sets the row factory for
|
|
31
40
|
dictionary-style access to columns.
|
|
32
41
|
|
|
33
|
-
:param db_path: Path to the SQLite database file
|
|
34
|
-
:param db_key: key to unlock the encrypted sqlite database,
|
|
35
|
-
|
|
42
|
+
:param db_path: Path to the SQLite database file
|
|
43
|
+
:param db_key: key to unlock the encrypted sqlite database,
|
|
44
|
+
unencrypted if sqlcipher3 not installed or unset
|
|
45
|
+
:param table_name: Name of the table to operate on, or create when importing
|
|
36
46
|
"""
|
|
37
47
|
self.db_path = db_path
|
|
38
48
|
self.table_name = table_name
|
|
39
49
|
self.db_key = db_key
|
|
50
|
+
self.debug = False
|
|
40
51
|
|
|
41
52
|
# Open connection
|
|
42
|
-
self.conn = sqlite3.connect(self.db_path)
|
|
43
|
-
self.conn.row_factory = sqlite3.Row
|
|
53
|
+
self.conn = sqlite3.connect(self.db_path) # pylint: disable=no-member
|
|
54
|
+
self.conn.row_factory = sqlite3.Row # pylint: disable=no-member
|
|
44
55
|
self.cursor = self.conn.cursor()
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
if HAS_SQLCIPHER and db_key:
|
|
58
|
+
# Apply SQLCipher key
|
|
59
|
+
self.cursor.execute(f"PRAGMA key='{db_key}'")
|
|
60
|
+
self.cursor.execute("PRAGMA cipher_compatibility = 4")
|
|
61
|
+
print(
|
|
62
|
+
"Cipher:", self.cursor.execute("PRAGMA cipher_version;").fetchone()[0]
|
|
63
|
+
)
|
|
64
|
+
print(f"Encrypted database {self.db_path} loaded securely.")
|
|
65
|
+
else:
|
|
66
|
+
print(f"Unencrypted database {self.db_path} loaded securely.")
|
|
51
67
|
|
|
52
68
|
# After table exists (or after import), build the dataclass
|
|
53
69
|
if self.table_exists():
|
|
@@ -55,18 +71,42 @@ class MojoSkel:
|
|
|
55
71
|
else:
|
|
56
72
|
self.row_class = None
|
|
57
73
|
|
|
58
|
-
def __iter__(self):
|
|
74
|
+
def __iter__(self) -> Iterator[Any]:
|
|
59
75
|
"""
|
|
60
|
-
Allow iterating over the class, by outputing all members
|
|
76
|
+
Allow iterating over the class, by outputing all members
|
|
61
77
|
"""
|
|
62
78
|
if not self.row_class:
|
|
63
79
|
raise RuntimeError("Table not loaded yet — no dataclass available")
|
|
64
80
|
return self._iter_rows()
|
|
65
81
|
|
|
66
|
-
def
|
|
82
|
+
def _row_to_obj(self, row: sqlite3.Row) -> Type[Any]:
|
|
67
83
|
"""
|
|
68
|
-
|
|
69
|
-
|
|
84
|
+
Convert an sqlite3 row into a dataclass object
|
|
85
|
+
|
|
86
|
+
:param row: The sqlite3 row to convert
|
|
87
|
+
|
|
88
|
+
:return: A dataclass object of the row
|
|
89
|
+
"""
|
|
90
|
+
row_dict = dict(row)
|
|
91
|
+
|
|
92
|
+
# Convert REAL → Decimal (including numeric strings)
|
|
93
|
+
for k, v in row_dict.items():
|
|
94
|
+
if isinstance(v, float):
|
|
95
|
+
row_dict[k] = Decimal(str(v))
|
|
96
|
+
elif isinstance(v, str):
|
|
97
|
+
try:
|
|
98
|
+
row_dict[k] = Decimal(v)
|
|
99
|
+
except InvalidOperation:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
return self.row_class(**row_dict)
|
|
103
|
+
|
|
104
|
+
def _iter_rows(self) -> Iterator[Any]:
|
|
105
|
+
"""
|
|
106
|
+
Iterate over table rows and yield dynamically-created dataclass objects
|
|
107
|
+
Converts REAL columns to Decimal automatically
|
|
108
|
+
|
|
109
|
+
:return: An interator of dataclass objects for rows
|
|
70
110
|
"""
|
|
71
111
|
|
|
72
112
|
sql = f'SELECT * FROM "{self.table_name}"'
|
|
@@ -75,29 +115,18 @@ class MojoSkel:
|
|
|
75
115
|
cur.execute(sql)
|
|
76
116
|
|
|
77
117
|
for row in cur.fetchall():
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
for k, v in row_dict.items():
|
|
82
|
-
if isinstance(v, float):
|
|
83
|
-
row_dict[k] = Decimal(str(v))
|
|
84
|
-
elif isinstance(v, str):
|
|
85
|
-
# Try converting numeric strings
|
|
86
|
-
try:
|
|
87
|
-
row_dict[k] = Decimal(v)
|
|
88
|
-
except InvalidOperation:
|
|
89
|
-
pass
|
|
90
|
-
|
|
91
|
-
yield self.row_class(**row_dict)
|
|
92
|
-
|
|
93
|
-
def _build_dataclass_from_table(self):
|
|
118
|
+
yield self._row_to_obj(row)
|
|
119
|
+
|
|
120
|
+
def _build_dataclass_from_table(self) -> Type[Any]:
|
|
94
121
|
"""
|
|
95
|
-
Dynamically create a dataclass from the table schema
|
|
122
|
+
Dynamically create a dataclass from the table schema
|
|
96
123
|
INTEGER → int
|
|
97
124
|
REAL → Decimal
|
|
98
125
|
TEXT → str
|
|
99
126
|
|
|
100
|
-
:return: A dataclass built from the table columns and types
|
|
127
|
+
:return: A dataclass built from the table columns and types
|
|
128
|
+
|
|
129
|
+
:raises ValueError: If no table
|
|
101
130
|
"""
|
|
102
131
|
self.cursor.execute(f'PRAGMA table_info("{self.table_name}")')
|
|
103
132
|
cols = self.cursor.fetchall()
|
|
@@ -120,30 +149,29 @@ class MojoSkel:
|
|
|
120
149
|
|
|
121
150
|
return make_dataclass(f"{self.table_name}_Row", fields)
|
|
122
151
|
|
|
123
|
-
def
|
|
152
|
+
def rename_old_table(self, existing: bool) -> str:
|
|
124
153
|
"""
|
|
125
|
-
|
|
126
|
-
If a previous table exists, generate a diff using
|
|
127
|
-
mojo_loader.diff_cipher_tables().
|
|
154
|
+
If there was an exising table rename for comparison
|
|
128
155
|
|
|
129
|
-
:param
|
|
156
|
+
:param existing: bool for table exists
|
|
157
|
+
|
|
158
|
+
:return: the old table name
|
|
130
159
|
"""
|
|
131
160
|
old_table = f"{self.table_name}_old"
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if had_existing:
|
|
161
|
+
self.conn.execute(f"DROP TABLE IF EXISTS {old_table}")
|
|
162
|
+
# Preserve existing table
|
|
163
|
+
if existing:
|
|
136
164
|
self.conn.execute(f"ALTER TABLE {self.table_name} RENAME TO {old_table}")
|
|
165
|
+
return old_table
|
|
137
166
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if not had_existing:
|
|
143
|
-
return
|
|
167
|
+
def print_diff(self, old_table: str):
|
|
168
|
+
"""
|
|
169
|
+
Print out diff between old and new db
|
|
144
170
|
|
|
171
|
+
:param old_table: The name the existing table was renamed to
|
|
172
|
+
"""
|
|
145
173
|
try:
|
|
146
|
-
#
|
|
174
|
+
# Diff old vs new (SQLCipher → sqlite3 → dataclasses)
|
|
147
175
|
diff_rows = mojo_loader.diff_cipher_tables(
|
|
148
176
|
self.conn,
|
|
149
177
|
new_table=self.table_name,
|
|
@@ -156,14 +184,52 @@ class MojoSkel:
|
|
|
156
184
|
print(diff.diff_type, diff.preview)
|
|
157
185
|
|
|
158
186
|
finally:
|
|
159
|
-
#
|
|
187
|
+
# Cleanup old table (always)
|
|
160
188
|
self.conn.execute(f"DROP TABLE {old_table}")
|
|
161
189
|
|
|
190
|
+
def download_csv(self, session: requests.Session, url: str):
|
|
191
|
+
"""
|
|
192
|
+
Download the CSV from url and import into the sqlite database
|
|
193
|
+
If a previous table exists, generate a diff
|
|
194
|
+
|
|
195
|
+
:param session: Requests session to use for download
|
|
196
|
+
:param url: url of the csv to download
|
|
197
|
+
"""
|
|
198
|
+
had_existing = self.table_exists()
|
|
199
|
+
old_table = self.rename_old_table(had_existing)
|
|
200
|
+
|
|
201
|
+
# Download CSV as new table
|
|
202
|
+
mojo_loader.download_csv_helper(self.conn, self.table_name, url, session)
|
|
203
|
+
self.row_class = self._build_dataclass_from_table()
|
|
204
|
+
|
|
205
|
+
if not had_existing:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
self.print_diff(old_table)
|
|
209
|
+
|
|
210
|
+
def import_csv(self, csv_path: Path):
|
|
211
|
+
"""
|
|
212
|
+
Import the passed CSV into the encrypted sqlite database
|
|
213
|
+
|
|
214
|
+
:param csv_path: Path like path of csv file
|
|
215
|
+
"""
|
|
216
|
+
had_existing = self.table_exists()
|
|
217
|
+
old_table = self.rename_old_table(had_existing)
|
|
218
|
+
|
|
219
|
+
# Import CSV as new table
|
|
220
|
+
mojo_loader.import_csv_helper(self.conn, self.table_name, csv_path)
|
|
221
|
+
self.row_class = self._build_dataclass_from_table()
|
|
222
|
+
|
|
223
|
+
if not had_existing:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
self.print_diff(old_table)
|
|
227
|
+
|
|
162
228
|
def show_table(self, limit: int = 2):
|
|
163
229
|
"""
|
|
164
|
-
Print the first few rows of the table as dictionaries
|
|
230
|
+
Print the first few rows of the table as dictionaries
|
|
165
231
|
|
|
166
|
-
:param limit: (optional) Number of rows to display. Defaults to 2
|
|
232
|
+
:param limit: (optional) Number of rows to display. Defaults to 2
|
|
167
233
|
"""
|
|
168
234
|
if self.table_exists():
|
|
169
235
|
self.cursor.execute(f'SELECT * FROM "{self.table_name}" LIMIT ?', (limit,))
|
|
@@ -178,7 +244,7 @@ class MojoSkel:
|
|
|
178
244
|
|
|
179
245
|
def count(self) -> int:
|
|
180
246
|
"""
|
|
181
|
-
:return: count of the number of rows in the table, or 0 if no table
|
|
247
|
+
:return: count of the number of rows in the table, or 0 if no table
|
|
182
248
|
"""
|
|
183
249
|
if self.table_exists():
|
|
184
250
|
self.cursor.execute(f'SELECT COUNT(*) FROM "{self.table_name}"')
|
|
@@ -187,33 +253,34 @@ class MojoSkel:
|
|
|
187
253
|
|
|
188
254
|
return 0
|
|
189
255
|
|
|
190
|
-
def get_row(
|
|
256
|
+
def get_row(
|
|
257
|
+
self, entry_name: str, entry_value: str, only_one: bool = True
|
|
258
|
+
) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
|
|
191
259
|
"""
|
|
192
|
-
Retrieve a single row matching column = value (case-insensitive)
|
|
260
|
+
Retrieve a single row matching column = value (case-insensitive)
|
|
193
261
|
|
|
194
|
-
:param entry_name: Column name to filter by
|
|
195
|
-
:param entry_value: Value to match
|
|
262
|
+
:param entry_name: Column name to filter by
|
|
263
|
+
:param entry_value: Value to match
|
|
264
|
+
:param only_one: If True (default), return the first matching row
|
|
265
|
+
If False, return a list of all matching rows
|
|
196
266
|
|
|
197
|
-
:return:
|
|
267
|
+
:return:
|
|
268
|
+
- If only_one=True → a single sqlite3.Row or None
|
|
269
|
+
- If only_one=False → list of sqlite3.Row (may be empty)
|
|
198
270
|
"""
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
f'SELECT * FROM "{self.table_name}" WHERE LOWER("{entry_name}") = LOWER(?)'
|
|
203
|
-
)
|
|
204
|
-
self.cursor.execute(query, (entry_value,))
|
|
205
|
-
row = self.cursor.fetchone()
|
|
206
|
-
return dict(row) if row else None
|
|
271
|
+
match_dict = {f"{entry_name}": entry_value}
|
|
272
|
+
|
|
273
|
+
return self.get_row_multi(match_dict, only_one)
|
|
207
274
|
|
|
208
275
|
def get_row_multi(
|
|
209
276
|
self, match_dict: dict, only_one: bool = True
|
|
210
277
|
) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
|
|
211
278
|
"""
|
|
212
|
-
Retrieve one or many rows matching multiple column=value pairs
|
|
279
|
+
Retrieve one or many rows matching multiple column=value pairs
|
|
213
280
|
|
|
214
|
-
:param match_dict: Dictionary of column names and values to match
|
|
215
|
-
:param only_one: If True (default), return the first matching row
|
|
216
|
-
If False, return a list of all matching rows
|
|
281
|
+
:param match_dict: Dictionary of column names and values to match
|
|
282
|
+
:param only_one: If True (default), return the first matching row
|
|
283
|
+
If False, return a list of all matching rows
|
|
217
284
|
|
|
218
285
|
:return:
|
|
219
286
|
- If only_one=True → a single sqlite3.Row or None
|
|
@@ -225,6 +292,23 @@ class MojoSkel:
|
|
|
225
292
|
for col, val in match_dict.items():
|
|
226
293
|
if val is None or val == "":
|
|
227
294
|
conditions.append(f'"{col}" IS NULL')
|
|
295
|
+
elif isinstance(val, Like):
|
|
296
|
+
conditions.append(f'LOWER("{col}") LIKE LOWER(?)')
|
|
297
|
+
values.append(val.pattern)
|
|
298
|
+
elif isinstance(val, (tuple, list)) and len(val) == 2:
|
|
299
|
+
lower, upper = val
|
|
300
|
+
if lower is not None and upper is not None:
|
|
301
|
+
conditions.append(f'"{col}" BETWEEN ? AND ?')
|
|
302
|
+
values.extend([lower, upper])
|
|
303
|
+
elif lower is not None:
|
|
304
|
+
conditions.append(f'"{col}" >= ?')
|
|
305
|
+
values.append(lower)
|
|
306
|
+
elif upper is not None:
|
|
307
|
+
conditions.append(f'"{col}" <= ?')
|
|
308
|
+
values.append(upper)
|
|
309
|
+
else:
|
|
310
|
+
# Both are None, effectively no condition on this column
|
|
311
|
+
pass
|
|
228
312
|
else:
|
|
229
313
|
conditions.append(f'"{col}" = ?')
|
|
230
314
|
values.append(
|
|
@@ -233,22 +317,27 @@ class MojoSkel:
|
|
|
233
317
|
else val
|
|
234
318
|
)
|
|
235
319
|
|
|
320
|
+
# Base query string
|
|
236
321
|
base_query = (
|
|
237
322
|
f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)}'
|
|
238
323
|
)
|
|
324
|
+
if self.debug:
|
|
325
|
+
print("Sql:")
|
|
326
|
+
pprint(base_query)
|
|
239
327
|
|
|
240
328
|
if only_one:
|
|
241
329
|
query = base_query + " LIMIT 1"
|
|
242
330
|
self.cursor.execute(query, values)
|
|
243
|
-
|
|
331
|
+
if row := self.cursor.fetchone():
|
|
332
|
+
return self._row_to_obj(row)
|
|
333
|
+
return None
|
|
244
334
|
|
|
245
|
-
# Return *all* rows
|
|
246
335
|
self.cursor.execute(base_query, values)
|
|
247
|
-
return self.cursor.fetchall()
|
|
336
|
+
return [self._row_to_obj(row) for row in self.cursor.fetchall()]
|
|
248
337
|
|
|
249
338
|
def table_exists(self) -> bool:
|
|
250
339
|
"""
|
|
251
|
-
Return
|
|
340
|
+
Return True or False if a table exists
|
|
252
341
|
"""
|
|
253
342
|
self.cursor.execute(
|
|
254
343
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
|
memberjojo/mojo_loader.py
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
Helper module for importing a CSV into a SQLite database
|
|
3
|
+
Helper module for importing a CSV into a SQLite database
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from collections import defaultdict, Counter
|
|
7
7
|
from csv import DictReader
|
|
8
8
|
from dataclasses import dataclass
|
|
9
|
+
from io import StringIO
|
|
9
10
|
from pathlib import Path
|
|
10
|
-
from typing import Any, Tuple
|
|
11
|
+
from typing import Any, IO, Tuple
|
|
11
12
|
|
|
12
13
|
import re
|
|
13
14
|
import sqlite3 as sqlite3_builtin
|
|
14
15
|
|
|
16
|
+
import requests
|
|
17
|
+
|
|
15
18
|
|
|
16
19
|
@dataclass(frozen=True)
|
|
17
20
|
class DiffRow:
|
|
18
21
|
"""
|
|
19
|
-
Represents a single diff result
|
|
22
|
+
Represents a single diff result
|
|
20
23
|
|
|
21
24
|
- diff_type: 'added' | 'deleted' | 'changed'
|
|
22
25
|
- preview: tuple of values, with preview[0] == key
|
|
@@ -33,11 +36,11 @@ class DiffRow:
|
|
|
33
36
|
|
|
34
37
|
def _normalize(name: str) -> str:
|
|
35
38
|
"""
|
|
36
|
-
Normalize a column name: lowercase, remove symbols, convert to snake case
|
|
39
|
+
Normalize a column name: lowercase, remove symbols, convert to snake case
|
|
37
40
|
|
|
38
|
-
:param name: Raw name to normalize
|
|
41
|
+
:param name: Raw name to normalize
|
|
39
42
|
|
|
40
|
-
:return: Normalized lowercase string in snake case with no symbols
|
|
43
|
+
:return: Normalized lowercase string in snake case with no symbols
|
|
41
44
|
"""
|
|
42
45
|
name = name.strip().lower()
|
|
43
46
|
name = re.sub(r"[^a-z0-9]+", "_", name)
|
|
@@ -46,7 +49,7 @@ def _normalize(name: str) -> str:
|
|
|
46
49
|
|
|
47
50
|
def _guess_type(value: any) -> str:
|
|
48
51
|
"""
|
|
49
|
-
Guess SQLite data type of a CSV value: 'INTEGER', 'REAL', or 'TEXT'
|
|
52
|
+
Guess SQLite data type of a CSV value: 'INTEGER', 'REAL', or 'TEXT'
|
|
50
53
|
|
|
51
54
|
:param value: entry from sqlite database to guess the type of
|
|
52
55
|
|
|
@@ -71,8 +74,8 @@ def _guess_type(value: any) -> str:
|
|
|
71
74
|
|
|
72
75
|
def infer_columns_from_rows(rows: list[dict]) -> dict[str, str]:
|
|
73
76
|
"""
|
|
74
|
-
Infer column types from CSV rows
|
|
75
|
-
Returns mapping: normalized column name -> SQLite type
|
|
77
|
+
Infer column types from CSV rows
|
|
78
|
+
Returns mapping: normalized column name -> SQLite type
|
|
76
79
|
|
|
77
80
|
:param rows: list of rows to use for inference
|
|
78
81
|
|
|
@@ -104,22 +107,18 @@ def infer_columns_from_rows(rows: list[dict]) -> dict[str, str]:
|
|
|
104
107
|
|
|
105
108
|
def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
|
|
106
109
|
"""
|
|
107
|
-
Generate CREATE TABLE SQL from column type mapping
|
|
110
|
+
Generate CREATE TABLE SQL from column type mapping
|
|
111
|
+
Adds an auto-incrementing rowid as the primary key
|
|
108
112
|
|
|
109
|
-
:param table_name: Table to use when creating columns
|
|
110
|
-
:param columns: dict of columns to create
|
|
113
|
+
:param table_name: Table to use when creating columns
|
|
114
|
+
:param columns: dict of columns to create
|
|
111
115
|
|
|
112
|
-
:return: SQL commands to create the table
|
|
116
|
+
:return: SQL commands to create the table
|
|
113
117
|
"""
|
|
114
|
-
col_defs = []
|
|
115
|
-
first = True
|
|
118
|
+
col_defs = ["rowid INTEGER PRIMARY KEY AUTOINCREMENT"]
|
|
116
119
|
|
|
117
120
|
for col, col_type in columns.items():
|
|
118
|
-
|
|
119
|
-
col_defs.append(f'"{col}" {col_type} PRIMARY KEY')
|
|
120
|
-
first = False
|
|
121
|
-
else:
|
|
122
|
-
col_defs.append(f'"{col}" {col_type}')
|
|
121
|
+
col_defs.append(f'"{col}" {col_type}')
|
|
123
122
|
|
|
124
123
|
return (
|
|
125
124
|
f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n' + ",\n".join(col_defs) + "\n)"
|
|
@@ -131,14 +130,47 @@ def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
|
|
|
131
130
|
# -----------------------
|
|
132
131
|
|
|
133
132
|
|
|
133
|
+
def import_data(conn, table_name: str, reader: DictReader):
|
|
134
|
+
"""
|
|
135
|
+
Import data in the DictReader into the SQLite3 database at conn
|
|
136
|
+
|
|
137
|
+
:param conn: SQLite database connection to use
|
|
138
|
+
:param table_name: Name of the table to import into
|
|
139
|
+
:param reader: A Dictreader object to import from
|
|
140
|
+
"""
|
|
141
|
+
inferred_cols = infer_columns_from_rows(reader)
|
|
142
|
+
|
|
143
|
+
cursor = conn.cursor()
|
|
144
|
+
# Drop existing table
|
|
145
|
+
cursor.execute(f'DROP TABLE IF EXISTS "{table_name}";')
|
|
146
|
+
|
|
147
|
+
# Create table
|
|
148
|
+
create_sql = _create_table_from_columns(table_name, inferred_cols)
|
|
149
|
+
cursor.execute(create_sql)
|
|
150
|
+
|
|
151
|
+
# Insert rows
|
|
152
|
+
cols = list(reader[0].keys())
|
|
153
|
+
norm_map = {c: _normalize(c) for c in cols}
|
|
154
|
+
colnames = ",".join(f'"{norm_map[c]}"' for c in cols)
|
|
155
|
+
placeholders = ",".join("?" for _ in cols)
|
|
156
|
+
insert_sql = f'INSERT INTO "{table_name}" ({colnames}) VALUES ({placeholders})'
|
|
157
|
+
|
|
158
|
+
for row in reader:
|
|
159
|
+
values = [row[c] if row[c] != "" else None for c in cols]
|
|
160
|
+
cursor.execute(insert_sql, values)
|
|
161
|
+
|
|
162
|
+
cursor.close()
|
|
163
|
+
conn.commit()
|
|
164
|
+
|
|
165
|
+
|
|
134
166
|
def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
135
167
|
"""
|
|
136
|
-
Import CSV into database using given cursor
|
|
137
|
-
Column types inferred automatically
|
|
168
|
+
Import CSV into database using given cursor
|
|
169
|
+
Column types inferred automatically
|
|
138
170
|
|
|
139
|
-
:param conn: SQLite database connection to use
|
|
140
|
-
:param table_name: Table to import the CSV into
|
|
141
|
-
:param csv_path: Path like path of the CSV file to import
|
|
171
|
+
:param conn: SQLite database connection to use
|
|
172
|
+
:param table_name: Table to import the CSV into
|
|
173
|
+
:param csv_path: Path like path of the CSV file to import
|
|
142
174
|
"""
|
|
143
175
|
if not csv_path.exists():
|
|
144
176
|
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
@@ -148,29 +180,7 @@ def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
|
148
180
|
reader = list(DictReader(f))
|
|
149
181
|
if not reader:
|
|
150
182
|
raise ValueError("CSV file is empty.")
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
cursor = conn.cursor()
|
|
154
|
-
# Drop existing table
|
|
155
|
-
cursor.execute(f'DROP TABLE IF EXISTS "{table_name}";')
|
|
156
|
-
|
|
157
|
-
# Create table
|
|
158
|
-
create_sql = _create_table_from_columns(table_name, inferred_cols)
|
|
159
|
-
cursor.execute(create_sql)
|
|
160
|
-
|
|
161
|
-
# Insert rows
|
|
162
|
-
cols = list(reader[0].keys())
|
|
163
|
-
norm_map = {c: _normalize(c) for c in cols}
|
|
164
|
-
colnames = ",".join(f'"{norm_map[c]}"' for c in cols)
|
|
165
|
-
placeholders = ",".join("?" for _ in cols)
|
|
166
|
-
insert_sql = f'INSERT INTO "{table_name}" ({colnames}) VALUES ({placeholders})'
|
|
167
|
-
|
|
168
|
-
for row in reader:
|
|
169
|
-
values = [row[c] if row[c] != "" else None for c in cols]
|
|
170
|
-
cursor.execute(insert_sql, values)
|
|
171
|
-
|
|
172
|
-
cursor.close()
|
|
173
|
-
conn.commit()
|
|
183
|
+
import_data(conn, table_name, reader)
|
|
174
184
|
|
|
175
185
|
|
|
176
186
|
# -----------------------
|
|
@@ -180,7 +190,7 @@ def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
|
180
190
|
|
|
181
191
|
def _diffrow_from_sql_row(row: sqlite3_builtin.Row) -> DiffRow:
|
|
182
192
|
"""
|
|
183
|
-
Convert a sqlite3.Row from generate_sql_diff into DiffRow
|
|
193
|
+
Convert a sqlite3.Row from generate_sql_diff into DiffRow
|
|
184
194
|
Row shape:
|
|
185
195
|
(diff_type, col1, col2, col3, ...)
|
|
186
196
|
|
|
@@ -195,7 +205,7 @@ def _diffrow_from_sql_row(row: sqlite3_builtin.Row) -> DiffRow:
|
|
|
195
205
|
|
|
196
206
|
|
|
197
207
|
def diff_cipher_tables(
|
|
198
|
-
|
|
208
|
+
conn,
|
|
199
209
|
*,
|
|
200
210
|
new_table: str,
|
|
201
211
|
old_table: str,
|
|
@@ -204,7 +214,7 @@ def diff_cipher_tables(
|
|
|
204
214
|
Copy old and new tables from SQLCipher into a single
|
|
205
215
|
in-memory sqlite3 database and diff them there.
|
|
206
216
|
|
|
207
|
-
:param
|
|
217
|
+
:param conn: sqlite connection to the db
|
|
208
218
|
:param new_table: name of the new table for comparison
|
|
209
219
|
:param old_table: name of the old table for comparison
|
|
210
220
|
|
|
@@ -216,8 +226,8 @@ def diff_cipher_tables(
|
|
|
216
226
|
|
|
217
227
|
try:
|
|
218
228
|
for table in (old_table, new_table):
|
|
219
|
-
#
|
|
220
|
-
schema_sql =
|
|
229
|
+
# Clone schema using SQLite itself
|
|
230
|
+
schema_sql = conn.execute(
|
|
221
231
|
"""
|
|
222
232
|
SELECT sql
|
|
223
233
|
FROM sqlite_master
|
|
@@ -232,7 +242,7 @@ def diff_cipher_tables(
|
|
|
232
242
|
plain.execute(schema_sql[0])
|
|
233
243
|
|
|
234
244
|
# 2. Copy data
|
|
235
|
-
rows =
|
|
245
|
+
rows = conn.execute(f"SELECT * FROM {table}")
|
|
236
246
|
cols = [d[0] for d in rows.description]
|
|
237
247
|
|
|
238
248
|
col_list = ", ".join(cols)
|
|
@@ -263,9 +273,9 @@ def _generate_sql_diff(
|
|
|
263
273
|
old_table: str,
|
|
264
274
|
) -> list[sqlite3_builtin.Row]:
|
|
265
275
|
"""
|
|
266
|
-
Generate a diff between two tables using standard SQLite features
|
|
276
|
+
Generate a diff between two tables using standard SQLite features
|
|
267
277
|
|
|
268
|
-
-
|
|
278
|
+
- Uses rowid as the primary key for joining
|
|
269
279
|
- Returned row shape:
|
|
270
280
|
(diff_type, preview_col1, preview_col2, preview_col3, ...)
|
|
271
281
|
|
|
@@ -276,7 +286,7 @@ def _generate_sql_diff(
|
|
|
276
286
|
:return: list of sqlite rows that are changed
|
|
277
287
|
"""
|
|
278
288
|
|
|
279
|
-
#
|
|
289
|
+
# Introspect schema (order-preserving)
|
|
280
290
|
cols_info = conn.execute(f"PRAGMA table_info({new_table})").fetchall()
|
|
281
291
|
|
|
282
292
|
if not cols_info:
|
|
@@ -284,16 +294,20 @@ def _generate_sql_diff(
|
|
|
284
294
|
|
|
285
295
|
cols = [row[1] for row in cols_info]
|
|
286
296
|
|
|
287
|
-
key
|
|
288
|
-
|
|
297
|
+
# Exclude rowid from consideration for key or data
|
|
298
|
+
main_cols = [c for c in cols if c != "rowid"]
|
|
289
299
|
|
|
290
|
-
#
|
|
300
|
+
# First column is key, others are for comparison
|
|
301
|
+
key = main_cols[0]
|
|
302
|
+
non_key_cols = main_cols[1:]
|
|
303
|
+
|
|
304
|
+
# Preview columns (key first, then others for readability)
|
|
291
305
|
preview_cols = [key] + non_key_cols[:5]
|
|
292
306
|
|
|
293
307
|
new_preview = ", ".join(f"n.{c}" for c in preview_cols)
|
|
294
308
|
old_preview = ", ".join(f"o.{c}" for c in preview_cols)
|
|
295
309
|
|
|
296
|
-
#
|
|
310
|
+
# Row-value comparison (NULL-safe)
|
|
297
311
|
if non_key_cols:
|
|
298
312
|
changed_predicate = (
|
|
299
313
|
f"({', '.join(f'n.{c}' for c in non_key_cols)}) "
|
|
@@ -333,3 +347,38 @@ def _generate_sql_diff(
|
|
|
333
347
|
"""
|
|
334
348
|
|
|
335
349
|
return list(conn.execute(sql))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def download_csv_helper(
|
|
353
|
+
conn, table_name: str, url: str, session: requests.Session
|
|
354
|
+
) -> IO[str]:
|
|
355
|
+
"""
|
|
356
|
+
Download url into a StringIO file object using streaming
|
|
357
|
+
and import into database
|
|
358
|
+
|
|
359
|
+
:param conn: The SQLite3 database connection to use
|
|
360
|
+
:param table_name: The name of the table to import it into
|
|
361
|
+
:param url: URL of the csv to download
|
|
362
|
+
:param session: A requests session to use for the download
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
print(f"Downloading from: {url}")
|
|
366
|
+
|
|
367
|
+
# Enable streaming
|
|
368
|
+
with session.get(url, stream=True) as resp:
|
|
369
|
+
resp.raise_for_status()
|
|
370
|
+
|
|
371
|
+
# Initialize the string buffer
|
|
372
|
+
string_buffer = StringIO()
|
|
373
|
+
|
|
374
|
+
# Stream decoded text
|
|
375
|
+
# decode_unicode=True uses the encoding from the response headers
|
|
376
|
+
for chunk in resp.iter_content(chunk_size=8192, decode_unicode=True):
|
|
377
|
+
if chunk:
|
|
378
|
+
string_buffer.write(chunk)
|
|
379
|
+
|
|
380
|
+
# Reset pointer to the beginning for DictReader
|
|
381
|
+
string_buffer.seek(0)
|
|
382
|
+
|
|
383
|
+
print(f"✅ Downloaded with encoding {resp.encoding}.")
|
|
384
|
+
import_data(conn, table_name, list(DictReader(string_buffer)))
|
memberjojo/mojo_member.py
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Member module for creating and interacting with a SQLite database
|
|
2
|
+
Member module for creating and interacting with a SQLite database
|
|
3
3
|
|
|
4
4
|
This module loads data from a `members.csv` file downloaded from Membermojo,
|
|
5
|
-
stores it in SQLite, and provides helper functions for member lookups
|
|
5
|
+
stores it in SQLite, and provides helper functions for member lookups
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from difflib import get_close_matches
|
|
8
9
|
from pathlib import Path
|
|
10
|
+
from pprint import pprint
|
|
9
11
|
from typing import Optional
|
|
10
12
|
from .mojo_common import MojoSkel
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
class Member(MojoSkel):
|
|
14
16
|
"""
|
|
15
|
-
Subclass of MojoSkel providing member-specific database functions
|
|
17
|
+
Subclass of MojoSkel providing member-specific database functions
|
|
16
18
|
|
|
17
19
|
This class connects to a SQLite database and supports importing member data
|
|
18
|
-
from CSV and performing queries like lookup by name or member number
|
|
20
|
+
from CSV and performing queries like lookup by name or member number
|
|
19
21
|
|
|
20
|
-
:param member_db_path (Path): Path to the SQLite database file
|
|
21
|
-
:param
|
|
22
|
-
|
|
22
|
+
:param member_db_path (Path): Path to the SQLite database file
|
|
23
|
+
:param db_key: key to unlock the encrypted sqlite database,
|
|
24
|
+
unencrypted if sqlcipher3 not installed or unset
|
|
25
|
+
:param table_name (str): (optional) Table name to use. Defaults to "members"
|
|
23
26
|
"""
|
|
24
27
|
|
|
25
28
|
def __init__(
|
|
@@ -29,23 +32,50 @@ class Member(MojoSkel):
|
|
|
29
32
|
table_name: str = "members",
|
|
30
33
|
):
|
|
31
34
|
"""
|
|
32
|
-
Initialize the Member database handler
|
|
35
|
+
Initialize the Member database handler
|
|
33
36
|
"""
|
|
34
37
|
super().__init__(member_db_path, db_key, table_name)
|
|
35
38
|
|
|
39
|
+
def get_bool(self, entry_name: str, member_number: int) -> bool:
|
|
40
|
+
"""
|
|
41
|
+
Return a bool for a member entry that is a tick box on membermojo
|
|
42
|
+
|
|
43
|
+
:param entry_name: The entry name to return as a bool
|
|
44
|
+
:param member_number: The member number to check value of entry_name
|
|
45
|
+
|
|
46
|
+
:return: True is entry is yes otherwise False
|
|
47
|
+
|
|
48
|
+
:raises ValueError: If entry name not found
|
|
49
|
+
"""
|
|
50
|
+
sql = f"""
|
|
51
|
+
SELECT "{entry_name}"
|
|
52
|
+
FROM "{self.table_name}"
|
|
53
|
+
WHERE "member_number" = ?
|
|
54
|
+
"""
|
|
55
|
+
self.cursor.execute(sql, (member_number,))
|
|
56
|
+
|
|
57
|
+
row = self.cursor.fetchone()
|
|
58
|
+
if row is None:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"❌ Cannot find: {entry_name} for member {member_number}."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
value = row[0]
|
|
64
|
+
return str(value).lower() == "yes"
|
|
65
|
+
|
|
36
66
|
def get_number_first_last(
|
|
37
67
|
self, first_name: str, last_name: str, found_error: bool = False
|
|
38
68
|
) -> Optional[int]:
|
|
39
69
|
"""
|
|
40
|
-
Find a member number based on first and last name (case-insensitive)
|
|
70
|
+
Find a member number based on first and last name (case-insensitive)
|
|
41
71
|
|
|
42
|
-
:param first_name: First name of the member
|
|
43
|
-
:param last_name: Last name of the member
|
|
44
|
-
:param found_error: (optional): If True, raises ValueError if not found
|
|
72
|
+
:param first_name: First name of the member
|
|
73
|
+
:param last_name: Last name of the member
|
|
74
|
+
:param found_error: (optional): If True, raises ValueError if not found
|
|
45
75
|
|
|
46
|
-
:return: The member number if found, otherwise None
|
|
76
|
+
:return: The member number if found, otherwise None
|
|
47
77
|
|
|
48
|
-
:raises ValueError: If not found and `found_error` is True
|
|
78
|
+
:raises ValueError: If not found and `found_error` is True
|
|
49
79
|
"""
|
|
50
80
|
sql = f"""
|
|
51
81
|
SELECT "member_number"
|
|
@@ -54,6 +84,12 @@ class Member(MojoSkel):
|
|
|
54
84
|
"""
|
|
55
85
|
self.cursor.execute(sql, (first_name, last_name))
|
|
56
86
|
result = self.cursor.fetchone()
|
|
87
|
+
if self.debug:
|
|
88
|
+
print("Sql:")
|
|
89
|
+
pprint(sql)
|
|
90
|
+
print(f"First Name: {first_name} Last Name: {last_name}")
|
|
91
|
+
print("Result:")
|
|
92
|
+
pprint(result[0])
|
|
57
93
|
|
|
58
94
|
if not result and found_error:
|
|
59
95
|
raise ValueError(
|
|
@@ -64,18 +100,22 @@ class Member(MojoSkel):
|
|
|
64
100
|
|
|
65
101
|
def get_number(self, full_name: str, found_error: bool = False) -> Optional[int]:
|
|
66
102
|
"""
|
|
67
|
-
Find a member number by passed full_name
|
|
103
|
+
Find a member number by passed full_name
|
|
68
104
|
Tries first and last, and then middle last if 3 words,
|
|
69
|
-
Then initial of first name if initials passed
|
|
105
|
+
Then initial of first name if initials passed
|
|
106
|
+
Finnaly a fuzzy lookup is tried
|
|
70
107
|
|
|
71
|
-
:param full_name: Full name of the member
|
|
72
|
-
:param found_error: (optional) Raise ValueError if not found
|
|
108
|
+
:param full_name: Full name of the member
|
|
109
|
+
:param found_error: (optional) Raise ValueError if not found
|
|
73
110
|
|
|
74
|
-
:return: Member number if found, else None
|
|
111
|
+
:return: Member number if found, else None
|
|
75
112
|
|
|
76
|
-
:raises ValueError: If not found and `found_error` is True
|
|
113
|
+
:raises ValueError: If not found and `found_error` is True
|
|
77
114
|
"""
|
|
78
|
-
result = self.get_mojo_name(full_name
|
|
115
|
+
result = self.get_mojo_name(full_name)
|
|
116
|
+
if not result:
|
|
117
|
+
result = self.get_fuzz_name(full_name, found_error)
|
|
118
|
+
|
|
79
119
|
if result:
|
|
80
120
|
return self.get_number_first_last(result[0], result[1])
|
|
81
121
|
return None
|
|
@@ -123,7 +163,7 @@ class Member(MojoSkel):
|
|
|
123
163
|
self, full_name: str, found_error: bool = False
|
|
124
164
|
) -> Optional[tuple]:
|
|
125
165
|
"""
|
|
126
|
-
Resolve a member name from a free-text full name
|
|
166
|
+
Resolve a member name from a free-text full name
|
|
127
167
|
|
|
128
168
|
**Search order**
|
|
129
169
|
|
|
@@ -132,14 +172,14 @@ class Member(MojoSkel):
|
|
|
132
172
|
3. initial 1st letter + last
|
|
133
173
|
4. initial 2nd letter + last (for two-letter initials)
|
|
134
174
|
|
|
135
|
-
Returns (first_name, last_name) or None
|
|
175
|
+
Returns (first_name, last_name) or None
|
|
136
176
|
|
|
137
|
-
:param full_name: Full name of the member to find
|
|
138
|
-
:param found_error: (optional) Raise ValueError if not found
|
|
177
|
+
:param full_name: Full name of the member to find
|
|
178
|
+
:param found_error: (optional) Raise ValueError if not found
|
|
139
179
|
|
|
140
|
-
:return: Membermojo name if found, else None
|
|
180
|
+
:return: Membermojo name if found, else None
|
|
141
181
|
|
|
142
|
-
:raises ValueError: If not found and `found_error` is True
|
|
182
|
+
:raises ValueError: If not found and `found_error` is True
|
|
143
183
|
"""
|
|
144
184
|
|
|
145
185
|
parts = full_name.strip().split()
|
|
@@ -207,11 +247,11 @@ class Member(MojoSkel):
|
|
|
207
247
|
|
|
208
248
|
def get_first_last_name(self, member_number: int) -> Optional[str]:
|
|
209
249
|
"""
|
|
210
|
-
Get full name for a given member number
|
|
250
|
+
Get full name for a given member number
|
|
211
251
|
|
|
212
|
-
:param member_number: Member number to look up
|
|
252
|
+
:param member_number: Member number to look up
|
|
213
253
|
|
|
214
|
-
:return: Full name as tuple, or None if not found
|
|
254
|
+
:return: Full name as tuple, or None if not found
|
|
215
255
|
"""
|
|
216
256
|
sql = f"""
|
|
217
257
|
SELECT "first_name", "last_name"
|
|
@@ -225,11 +265,11 @@ class Member(MojoSkel):
|
|
|
225
265
|
|
|
226
266
|
def get_name(self, member_number: int) -> Optional[str]:
|
|
227
267
|
"""
|
|
228
|
-
Get full name for a given member number
|
|
268
|
+
Get full name for a given member number
|
|
229
269
|
|
|
230
|
-
:param member_number: Member number to look up
|
|
270
|
+
:param member_number: Member number to look up
|
|
231
271
|
|
|
232
|
-
:return: Full name as "First Last", or None if not found
|
|
272
|
+
:return: Full name as "First Last", or None if not found
|
|
233
273
|
"""
|
|
234
274
|
|
|
235
275
|
result = self.get_first_last_name(member_number)
|
|
@@ -238,3 +278,39 @@ class Member(MojoSkel):
|
|
|
238
278
|
first_name, last_name = result
|
|
239
279
|
return f"{first_name} {last_name}"
|
|
240
280
|
return None
|
|
281
|
+
|
|
282
|
+
def get_fuzz_name(self, name: str, found_error: bool = False):
|
|
283
|
+
"""
|
|
284
|
+
Fuzzy search for members by name using partial matching
|
|
285
|
+
Searches across first_name and last_name fields
|
|
286
|
+
|
|
287
|
+
:param name: Free text name to search for (partial match)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
:return: Tuple of (first_name, last_name) or None
|
|
291
|
+
|
|
292
|
+
:raises ValueError: If not found and `found_error` is True
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
name = name.strip().lower()
|
|
296
|
+
|
|
297
|
+
# Get all members
|
|
298
|
+
self.cursor.execute(
|
|
299
|
+
f'SELECT *, LOWER(first_name || " " || last_name) AS full FROM "{self.table_name}"'
|
|
300
|
+
)
|
|
301
|
+
rows = self.cursor.fetchall()
|
|
302
|
+
|
|
303
|
+
choices = [row["full"] for row in rows]
|
|
304
|
+
matches = get_close_matches(name, choices, n=1, cutoff=0.7)
|
|
305
|
+
|
|
306
|
+
if not matches:
|
|
307
|
+
if found_error:
|
|
308
|
+
raise ValueError(
|
|
309
|
+
f"❌ Cannot find {name} in member database with fuzzy match."
|
|
310
|
+
)
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
match = matches[0]
|
|
314
|
+
# return the sqlite row for the best match
|
|
315
|
+
row = next(r for r in rows if r["full"] == match)
|
|
316
|
+
return (row["first_name"], row["last_name"])
|
memberjojo/mojo_transaction.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Module to import and interact with Membermojo completed_payments.csv data in SQLite
|
|
2
|
+
Module to import and interact with Membermojo completed_payments.csv data in SQLite
|
|
3
3
|
|
|
4
4
|
Provides automatic column type inference, robust CSV importing, and
|
|
5
|
-
helper methods for querying the database
|
|
5
|
+
helper methods for querying the database
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from .mojo_common import MojoSkel
|
|
@@ -10,14 +10,15 @@ from .mojo_common import MojoSkel
|
|
|
10
10
|
|
|
11
11
|
class Transaction(MojoSkel):
|
|
12
12
|
"""
|
|
13
|
-
Handles importing and querying completed payment data
|
|
13
|
+
Handles importing and querying completed payment data
|
|
14
14
|
|
|
15
15
|
Extends:
|
|
16
|
-
MojoSkel: Base class with transaction database operations
|
|
16
|
+
MojoSkel: Base class with transaction database operations
|
|
17
17
|
|
|
18
|
-
:param payment_db_path: Path to the SQLite database
|
|
19
|
-
:param
|
|
20
|
-
|
|
18
|
+
:param payment_db_path: Path to the SQLite database
|
|
19
|
+
:param db_key: key to unlock the encrypted sqlite database,
|
|
20
|
+
unencrypted if sqlcipher3 not installed or unset
|
|
21
|
+
:param table_name: (optional) Name of the table. Defaults to "payments"
|
|
21
22
|
"""
|
|
22
23
|
|
|
23
24
|
def __init__(
|
|
@@ -27,6 +28,6 @@ class Transaction(MojoSkel):
|
|
|
27
28
|
table_name: str = "payments",
|
|
28
29
|
):
|
|
29
30
|
"""
|
|
30
|
-
Initialize the Transaction object
|
|
31
|
+
Initialize the Transaction object
|
|
31
32
|
"""
|
|
32
33
|
super().__init__(payment_db_path, db_key, table_name)
|
memberjojo/sql_query.py
ADDED
memberjojo/url.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A class for managing Membermojo URLs
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class URL:
|
|
7
|
+
"""A class for managing Membermojo URLs"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, shortname: str):
|
|
10
|
+
"""
|
|
11
|
+
Ininitalise the class
|
|
12
|
+
|
|
13
|
+
:param shortname: the shortname setting on membermojo
|
|
14
|
+
"""
|
|
15
|
+
self.shortname = shortname
|
|
16
|
+
self.base_url = "https://membermojo.co.uk"
|
|
17
|
+
|
|
18
|
+
def make_url(self, endpoint: str) -> str:
|
|
19
|
+
"""
|
|
20
|
+
return a whole url for endpoint
|
|
21
|
+
|
|
22
|
+
:param endpoint: the endpoint to make url for
|
|
23
|
+
|
|
24
|
+
:return: a complete url
|
|
25
|
+
"""
|
|
26
|
+
return f"{self.base_url}/{self.shortname}/{endpoint}"
|
|
27
|
+
|
|
28
|
+
def members(self, state: str = "") -> str:
|
|
29
|
+
"""
|
|
30
|
+
return the active, expired, or archived urls
|
|
31
|
+
|
|
32
|
+
:param state: membership state to return
|
|
33
|
+
|
|
34
|
+
:return: url for the state
|
|
35
|
+
|
|
36
|
+
state:
|
|
37
|
+
"active" or "" -> active members
|
|
38
|
+
"expired" -> expired members
|
|
39
|
+
"archived" -> archived members
|
|
40
|
+
"""
|
|
41
|
+
if state not in {"", "expired", "archived"}:
|
|
42
|
+
raise ValueError(f"Invalid member state: {state}")
|
|
43
|
+
|
|
44
|
+
if state == "active":
|
|
45
|
+
state = ""
|
|
46
|
+
suffix = f"_{state}" if state else ""
|
|
47
|
+
return f"{self.membership}/download{suffix}_members"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def login(self):
|
|
51
|
+
"""Returns the membermojo login URL"""
|
|
52
|
+
return self.make_url("signin_password")
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def membership(self):
|
|
56
|
+
"""Returns the URL for membership"""
|
|
57
|
+
return self.make_url("membership")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def completed_payments(self):
|
|
61
|
+
"""Returns the completed payments download URL"""
|
|
62
|
+
return f"{self.membership}/download_completed_payments?state=CO"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def pending_aproval(self):
|
|
66
|
+
"""Returns the members pending approval URL"""
|
|
67
|
+
return f"{self.membership}/download_pending_approval_members"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def pending_completion(self):
|
|
71
|
+
"""Returns the members pending completion URL"""
|
|
72
|
+
return f"{self.membership}/download_pending_completion_members"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def pending_payments(self):
|
|
76
|
+
"""Returns the members pending payments URL"""
|
|
77
|
+
return f"{self.membership}/download_pending_payments"
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def test(self):
|
|
81
|
+
"""Returns the test URL for login verification"""
|
|
82
|
+
return self.membership
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memberjojo
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3
|
|
4
4
|
Summary: memberjojo - tools for working with members.
|
|
5
5
|
Author-email: Duncan Bellamy <dunk@denkimushi.com>
|
|
6
6
|
Requires-Python: >=3.8
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
10
|
-
Requires-Dist: sqlcipher3
|
|
11
10
|
Requires-Dist: coverage ; extra == "dev"
|
|
12
11
|
Requires-Dist: flit ; extra == "dev"
|
|
13
12
|
Requires-Dist: pytest ; extra == "dev"
|
|
@@ -17,28 +16,34 @@ Requires-Dist: sphinx>=7.0 ; extra == "docs"
|
|
|
17
16
|
Requires-Dist: sphinx-autodoc-typehints ; extra == "docs"
|
|
18
17
|
Requires-Dist: black ; extra == "lint"
|
|
19
18
|
Requires-Dist: pylint ; extra == "lint"
|
|
19
|
+
Requires-Dist: sqlcipher3 ; extra == "sqlcipher"
|
|
20
20
|
Project-URL: Home, https://github.com/a16bitsysop/memberjojo
|
|
21
21
|
Provides-Extra: dev
|
|
22
22
|
Provides-Extra: docs
|
|
23
23
|
Provides-Extra: lint
|
|
24
|
+
Provides-Extra: sqlcipher
|
|
24
25
|
|
|
25
26
|
# memberjojo
|
|
26
27
|
|
|
27
28
|
`memberjojo` is a Python library for using [Membermojo](http://membermojo.co.uk/)
|
|
28
29
|
data from CSV imports.\
|
|
29
30
|
It provides member database, and completed payments querying.\
|
|
30
|
-
This is done in a local SQLite database which is encrypted
|
|
31
|
-
anything on Membermojo.\
|
|
31
|
+
This is done in a local SQLite database which is optionally encrypted if sqlcipher3
|
|
32
|
+
is installed, and does not alter anything on Membermojo.\
|
|
32
33
|
It provides tools to load, and query membership and transaction data efficiently
|
|
33
34
|
without having to use SQLite directly.\
|
|
34
35
|
When importing CSV files existing entries are dropped before import, so you can
|
|
35
|
-
just import the latest download and the local database is updated
|
|
36
|
+
just import the latest download and the local database is updated with a summary
|
|
37
|
+
diff printed out.
|
|
38
|
+
|
|
39
|
+
Using the download_csv function the csv can be downloaded directly into the db,
|
|
40
|
+
which can also be in memory if :memory: is used as the db path.
|
|
36
41
|
|
|
37
42
|
---
|
|
38
43
|
|
|
39
44
|
## Installation
|
|
40
45
|
|
|
41
|
-
Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
|
|
46
|
+
Installing via `pip` on macos with optional `sqlcipher` installed via homebrew:\
|
|
42
47
|
(The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH` and
|
|
43
48
|
`LIBRARY_PATH` are needed for the `libsqlcipher` files to be found)
|
|
44
49
|
|
|
@@ -46,13 +51,19 @@ Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
|
|
|
46
51
|
brew install sqlcipher
|
|
47
52
|
export C_INCLUDE_PATH="/opt/homebrew/opt/sqlcipher/include"
|
|
48
53
|
export LIBRARY_PATH="/opt/homebrew/opt/sqlcipher/lib"
|
|
49
|
-
pip install memberjojo
|
|
54
|
+
pip install memberjojo[sqlciper]
|
|
50
55
|
```
|
|
51
56
|
|
|
52
57
|
Installing via `pip` on ubuntu:
|
|
53
58
|
|
|
54
59
|
```bash
|
|
55
60
|
sudo apt-get --no-install-recommends --no-install-suggests install libsqlcipher-dev
|
|
61
|
+
pip install memberjojo[sqlcipher]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Installing via `pip` without sqlcipher:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
56
67
|
pip install memberjojo
|
|
57
68
|
```
|
|
58
69
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
memberjojo/__init__.py,sha256=kO0EB2VTfHQFyqERL0qKRlYzAkD_cTm-b3q3uT1r91g,343
|
|
2
|
+
memberjojo/_version.py,sha256=2i05UdhZ8zjlTikPn5gGyFdwY-FJwBWYSRHEBlHO5KU,699
|
|
3
|
+
memberjojo/download.py,sha256=N8s5kFm58gbOaPyWvXxRigbMk5J6vjOx4_GX--JtWFY,4387
|
|
4
|
+
memberjojo/mojo_common.py,sha256=lE9LVVMGI5mbw5n6ZItvqSdswb4sJfLdwfRsgEocGuA,11415
|
|
5
|
+
memberjojo/mojo_loader.py,sha256=QOEPqVrswI82xqkj9CbtGmjRX9sqpRWZBSyhEnm82Os,10758
|
|
6
|
+
memberjojo/mojo_member.py,sha256=mr-4C4W_swzxQ1PcNi0884k59eRGo8fABAEQ0WVjyRo,10472
|
|
7
|
+
memberjojo/mojo_transaction.py,sha256=KCbhrY1Wccs_GoXHtuXgohn5MdxnUiMpKdhcVE348KU,933
|
|
8
|
+
memberjojo/sql_query.py,sha256=4T6EVYZo7q0VQ2Ah6uuX20Ub_g0RsBoKs1qrZmqRK7w,185
|
|
9
|
+
memberjojo/url.py,sha256=vx-l1FlrohlpAExPICU5UYRl34z_tYXvWLzGAaprdXI,2320
|
|
10
|
+
memberjojo-2.3.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
|
|
11
|
+
memberjojo-2.3.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
12
|
+
memberjojo-2.3.dist-info/METADATA,sha256=_1PYJvM6M4f82d4WB-f0tOy7TghxL86HbmURa8RWT3U,3964
|
|
13
|
+
memberjojo-2.3.dist-info/RECORD,,
|
memberjojo-2.2.dist-info/RECORD
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
memberjojo/__init__.py,sha256=JGhIJ1HGbzrwzF4HsopOdP4eweLjUmFjbIDvMbk1MKI,291
|
|
2
|
-
memberjojo/_version.py,sha256=_Nk5-Ctb0Jdj1fjUXI6x5dxb-FwdE56jfaBDPomGv_A,699
|
|
3
|
-
memberjojo/mojo_common.py,sha256=DqJ3NKq1Lrcv8NejJFR3ZHlwZifTjqIZv6Vp0R8yOQQ,8340
|
|
4
|
-
memberjojo/mojo_loader.py,sha256=pKqp7QiirThYd6gIy1LQ48GbV2DVFL0yZSLUWxuTLa0,9232
|
|
5
|
-
memberjojo/mojo_member.py,sha256=3UeBIVtSXBCUpvuGN9fIJ0zgXu6vXhs4wFDHeDnOG0o,8078
|
|
6
|
-
memberjojo/mojo_transaction.py,sha256=EuP_iu5R5HHCkr6PaJdWG0BP-XFMAjl-YPuCDVnKaRE,916
|
|
7
|
-
memberjojo-2.2.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
|
|
8
|
-
memberjojo-2.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
9
|
-
memberjojo-2.2.dist-info/METADATA,sha256=p8d0fTLtfNk5Hir3CRwjBl5WhSYrZL4UpiokpsE0Wsk,3593
|
|
10
|
-
memberjojo-2.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|