memberjojo 2.1__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 +190 -71
- memberjojo/mojo_loader.py +283 -39
- 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.1.dist-info → memberjojo-2.3.dist-info}/METADATA +20 -11
- memberjojo-2.3.dist-info/RECORD +13 -0
- memberjojo/config.py +0 -5
- memberjojo-2.1.dist-info/RECORD +0 -11
- {memberjojo-2.1.dist-info → memberjojo-2.3.dist-info}/WHEEL +0 -0
- {memberjojo-2.1.dist-info → memberjojo-2.3.dist-info}/licenses/LICENSE +0 -0
memberjojo/mojo_loader.py
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
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
|
+
from collections import defaultdict, Counter
|
|
6
7
|
from csv import DictReader
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from io import StringIO
|
|
7
10
|
from pathlib import Path
|
|
11
|
+
from typing import Any, IO, Tuple
|
|
12
|
+
|
|
8
13
|
import re
|
|
9
|
-
|
|
14
|
+
import sqlite3 as sqlite3_builtin
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class DiffRow:
|
|
21
|
+
"""
|
|
22
|
+
Represents a single diff result
|
|
23
|
+
|
|
24
|
+
- diff_type: 'added' | 'deleted' | 'changed'
|
|
25
|
+
- preview: tuple of values, with preview[0] == key
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
diff_type: str
|
|
29
|
+
preview: Tuple[Any, ...]
|
|
30
|
+
|
|
10
31
|
|
|
11
32
|
# -----------------------
|
|
12
33
|
# Normalization & Type Guessing
|
|
@@ -15,11 +36,11 @@ from collections import defaultdict, Counter
|
|
|
15
36
|
|
|
16
37
|
def _normalize(name: str) -> str:
|
|
17
38
|
"""
|
|
18
|
-
Normalize a column name: lowercase, remove symbols, convert to snake case
|
|
39
|
+
Normalize a column name: lowercase, remove symbols, convert to snake case
|
|
19
40
|
|
|
20
|
-
:param name: Raw name to normalize
|
|
41
|
+
:param name: Raw name to normalize
|
|
21
42
|
|
|
22
|
-
:return: Normalized lowercase string in snake case with no symbols
|
|
43
|
+
:return: Normalized lowercase string in snake case with no symbols
|
|
23
44
|
"""
|
|
24
45
|
name = name.strip().lower()
|
|
25
46
|
name = re.sub(r"[^a-z0-9]+", "_", name)
|
|
@@ -28,7 +49,11 @@ def _normalize(name: str) -> str:
|
|
|
28
49
|
|
|
29
50
|
def _guess_type(value: any) -> str:
|
|
30
51
|
"""
|
|
31
|
-
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'
|
|
53
|
+
|
|
54
|
+
:param value: entry from sqlite database to guess the type of
|
|
55
|
+
|
|
56
|
+
:return: string of the type, TEXT, INTEGER, REAL
|
|
32
57
|
"""
|
|
33
58
|
if value is None:
|
|
34
59
|
return "TEXT"
|
|
@@ -49,8 +74,12 @@ def _guess_type(value: any) -> str:
|
|
|
49
74
|
|
|
50
75
|
def infer_columns_from_rows(rows: list[dict]) -> dict[str, str]:
|
|
51
76
|
"""
|
|
52
|
-
Infer column types from CSV rows
|
|
53
|
-
Returns mapping: normalized column name -> SQLite type
|
|
77
|
+
Infer column types from CSV rows
|
|
78
|
+
Returns mapping: normalized column name -> SQLite type
|
|
79
|
+
|
|
80
|
+
:param rows: list of rows to use for inference
|
|
81
|
+
|
|
82
|
+
:return: dict of name, type for columns
|
|
54
83
|
"""
|
|
55
84
|
type_counters = defaultdict(Counter)
|
|
56
85
|
|
|
@@ -78,18 +107,21 @@ def infer_columns_from_rows(rows: list[dict]) -> dict[str, str]:
|
|
|
78
107
|
|
|
79
108
|
def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
|
|
80
109
|
"""
|
|
81
|
-
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
|
|
82
112
|
|
|
83
|
-
:param table_name: Table to use when creating columns
|
|
84
|
-
: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
|
|
85
115
|
|
|
86
|
-
:return: SQL commands to create the table
|
|
116
|
+
:return: SQL commands to create the table
|
|
87
117
|
"""
|
|
88
|
-
|
|
118
|
+
col_defs = ["rowid INTEGER PRIMARY KEY AUTOINCREMENT"]
|
|
119
|
+
|
|
120
|
+
for col, col_type in columns.items():
|
|
121
|
+
col_defs.append(f'"{col}" {col_type}')
|
|
122
|
+
|
|
89
123
|
return (
|
|
90
|
-
f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n'
|
|
91
|
-
+ ",\n".join(column_defs)
|
|
92
|
-
+ "\n)"
|
|
124
|
+
f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n' + ",\n".join(col_defs) + "\n)"
|
|
93
125
|
)
|
|
94
126
|
|
|
95
127
|
|
|
@@ -98,14 +130,47 @@ def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
|
|
|
98
130
|
# -----------------------
|
|
99
131
|
|
|
100
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
|
+
|
|
101
166
|
def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
102
167
|
"""
|
|
103
|
-
Import CSV into database using given cursor
|
|
104
|
-
Column types inferred automatically
|
|
168
|
+
Import CSV into database using given cursor
|
|
169
|
+
Column types inferred automatically
|
|
105
170
|
|
|
106
|
-
:param conn: SQLite database connection to use
|
|
107
|
-
:param table_name: Table to import the CSV into
|
|
108
|
-
: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
|
|
109
174
|
"""
|
|
110
175
|
if not csv_path.exists():
|
|
111
176
|
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
@@ -115,26 +180,205 @@ def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
|
115
180
|
reader = list(DictReader(f))
|
|
116
181
|
if not reader:
|
|
117
182
|
raise ValueError("CSV file is empty.")
|
|
118
|
-
|
|
183
|
+
import_data(conn, table_name, reader)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# -----------------------
|
|
187
|
+
# diff generation
|
|
188
|
+
# -----------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _diffrow_from_sql_row(row: sqlite3_builtin.Row) -> DiffRow:
|
|
192
|
+
"""
|
|
193
|
+
Convert a sqlite3.Row from generate_sql_diff into DiffRow
|
|
194
|
+
Row shape:
|
|
195
|
+
(diff_type, col1, col2, col3, ...)
|
|
119
196
|
|
|
120
|
-
|
|
121
|
-
# Drop existing table
|
|
122
|
-
cursor.execute(f'DROP TABLE IF EXISTS "{table_name}";')
|
|
197
|
+
:param row: Row from sqlite3 database to create a dataclass entry from
|
|
123
198
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
199
|
+
:return: A dataclass of the row
|
|
200
|
+
"""
|
|
201
|
+
return DiffRow(
|
|
202
|
+
diff_type=row[0],
|
|
203
|
+
preview=tuple(row[1:]),
|
|
204
|
+
)
|
|
127
205
|
|
|
128
|
-
# Insert rows
|
|
129
|
-
cols = list(reader[0].keys())
|
|
130
|
-
norm_map = {c: _normalize(c) for c in cols}
|
|
131
|
-
colnames = ",".join(f'"{norm_map[c]}"' for c in cols)
|
|
132
|
-
placeholders = ",".join("?" for _ in cols)
|
|
133
|
-
insert_sql = f'INSERT INTO "{table_name}" ({colnames}) VALUES ({placeholders})'
|
|
134
206
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
207
|
+
def diff_cipher_tables(
|
|
208
|
+
conn,
|
|
209
|
+
*,
|
|
210
|
+
new_table: str,
|
|
211
|
+
old_table: str,
|
|
212
|
+
) -> list[DiffRow]:
|
|
213
|
+
"""
|
|
214
|
+
Copy old and new tables from SQLCipher into a single
|
|
215
|
+
in-memory sqlite3 database and diff them there.
|
|
138
216
|
|
|
139
|
-
|
|
140
|
-
|
|
217
|
+
:param conn: sqlite connection to the db
|
|
218
|
+
:param new_table: name of the new table for comparison
|
|
219
|
+
:param old_table: name of the old table for comparison
|
|
220
|
+
|
|
221
|
+
:return: a list of DiffRow entries of the changed rows
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
plain = sqlite3_builtin.connect(":memory:")
|
|
225
|
+
plain.row_factory = sqlite3_builtin.Row
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
for table in (old_table, new_table):
|
|
229
|
+
# Clone schema using SQLite itself
|
|
230
|
+
schema_sql = conn.execute(
|
|
231
|
+
"""
|
|
232
|
+
SELECT sql
|
|
233
|
+
FROM sqlite_master
|
|
234
|
+
WHERE type='table' AND name=?
|
|
235
|
+
""",
|
|
236
|
+
(table,),
|
|
237
|
+
).fetchone()
|
|
238
|
+
|
|
239
|
+
if schema_sql is None:
|
|
240
|
+
raise RuntimeError(f"Table {table!r} not found in cipher DB")
|
|
241
|
+
|
|
242
|
+
plain.execute(schema_sql[0])
|
|
243
|
+
|
|
244
|
+
# 2. Copy data
|
|
245
|
+
rows = conn.execute(f"SELECT * FROM {table}")
|
|
246
|
+
cols = [d[0] for d in rows.description]
|
|
247
|
+
|
|
248
|
+
col_list = ", ".join(cols)
|
|
249
|
+
placeholders = ", ".join("?" for _ in cols)
|
|
250
|
+
|
|
251
|
+
plain.executemany(
|
|
252
|
+
f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
|
|
253
|
+
rows.fetchall(),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# 3. Run sqlite-only diff
|
|
257
|
+
rows = _generate_sql_diff(
|
|
258
|
+
plain,
|
|
259
|
+
new_table=new_table,
|
|
260
|
+
old_table=old_table,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return [_diffrow_from_sql_row(r) for r in rows]
|
|
264
|
+
|
|
265
|
+
finally:
|
|
266
|
+
plain.close()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _generate_sql_diff(
|
|
270
|
+
conn: sqlite3_builtin.Connection,
|
|
271
|
+
*,
|
|
272
|
+
new_table: str,
|
|
273
|
+
old_table: str,
|
|
274
|
+
) -> list[sqlite3_builtin.Row]:
|
|
275
|
+
"""
|
|
276
|
+
Generate a diff between two tables using standard SQLite features
|
|
277
|
+
|
|
278
|
+
- Uses rowid as the primary key for joining
|
|
279
|
+
- Returned row shape:
|
|
280
|
+
(diff_type, preview_col1, preview_col2, preview_col3, ...)
|
|
281
|
+
|
|
282
|
+
:param conn: sqlite connection to the db, using python builtin sqlite
|
|
283
|
+
:param new_table: name of the new table to use for comparison
|
|
284
|
+
:param old_table: name of the old table to use for comparison
|
|
285
|
+
|
|
286
|
+
:return: list of sqlite rows that are changed
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
# Introspect schema (order-preserving)
|
|
290
|
+
cols_info = conn.execute(f"PRAGMA table_info({new_table})").fetchall()
|
|
291
|
+
|
|
292
|
+
if not cols_info:
|
|
293
|
+
raise RuntimeError(f"Table {new_table!r} has no columns")
|
|
294
|
+
|
|
295
|
+
cols = [row[1] for row in cols_info]
|
|
296
|
+
|
|
297
|
+
# Exclude rowid from consideration for key or data
|
|
298
|
+
main_cols = [c for c in cols if c != "rowid"]
|
|
299
|
+
|
|
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)
|
|
305
|
+
preview_cols = [key] + non_key_cols[:5]
|
|
306
|
+
|
|
307
|
+
new_preview = ", ".join(f"n.{c}" for c in preview_cols)
|
|
308
|
+
old_preview = ", ".join(f"o.{c}" for c in preview_cols)
|
|
309
|
+
|
|
310
|
+
# Row-value comparison (NULL-safe)
|
|
311
|
+
if non_key_cols:
|
|
312
|
+
changed_predicate = (
|
|
313
|
+
f"({', '.join(f'n.{c}' for c in non_key_cols)}) "
|
|
314
|
+
f"IS NOT "
|
|
315
|
+
f"({', '.join(f'o.{c}' for c in non_key_cols)})"
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
# Key-only table
|
|
319
|
+
changed_predicate = "0"
|
|
320
|
+
|
|
321
|
+
sql = f"""
|
|
322
|
+
WITH
|
|
323
|
+
added AS (
|
|
324
|
+
SELECT 'added' AS diff_type, {new_preview}
|
|
325
|
+
FROM {new_table} n
|
|
326
|
+
LEFT JOIN {old_table} o USING ({key})
|
|
327
|
+
WHERE o.{key} IS NULL
|
|
328
|
+
),
|
|
329
|
+
deleted AS (
|
|
330
|
+
SELECT 'deleted' AS diff_type, {old_preview}
|
|
331
|
+
FROM {old_table} o
|
|
332
|
+
LEFT JOIN {new_table} n USING ({key})
|
|
333
|
+
WHERE n.{key} IS NULL
|
|
334
|
+
),
|
|
335
|
+
changed AS (
|
|
336
|
+
SELECT 'changed' AS diff_type, {new_preview}
|
|
337
|
+
FROM {new_table} n
|
|
338
|
+
JOIN {old_table} o USING ({key})
|
|
339
|
+
WHERE {changed_predicate}
|
|
340
|
+
)
|
|
341
|
+
SELECT * FROM added
|
|
342
|
+
UNION ALL
|
|
343
|
+
SELECT * FROM deleted
|
|
344
|
+
UNION ALL
|
|
345
|
+
SELECT * FROM changed
|
|
346
|
+
ORDER BY {key};
|
|
347
|
+
"""
|
|
348
|
+
|
|
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)
|