memberjojo 2.2__py3-none-any.whl → 3.0__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 +202 -87
- memberjojo/mojo_loader.py +127 -59
- memberjojo/mojo_member.py +122 -33
- memberjojo/mojo_transaction.py +9 -8
- memberjojo/sql_query.py +12 -0
- memberjojo/url.py +82 -0
- {memberjojo-2.2.dist-info → memberjojo-3.0.dist-info}/METADATA +18 -7
- memberjojo-3.0.dist-info/RECORD +13 -0
- memberjojo-2.2.dist-info/RECORD +0 -10
- {memberjojo-2.2.dist-info → memberjojo-3.0.dist-info}/WHEEL +0 -0
- {memberjojo-2.2.dist-info → memberjojo-3.0.dist-info}/licenses/LICENSE +0 -0
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,75 +107,100 @@ 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)"
|
|
126
125
|
)
|
|
127
126
|
|
|
128
127
|
|
|
128
|
+
def table_exists(cursor, table_name: str) -> bool:
|
|
129
|
+
"""
|
|
130
|
+
Return True or False if a table exists
|
|
131
|
+
|
|
132
|
+
:param cursor: SQLite cursor of db to find table in
|
|
133
|
+
:param table_name: name of the table to check existance of
|
|
134
|
+
|
|
135
|
+
:return: bool of existence
|
|
136
|
+
"""
|
|
137
|
+
cursor.execute(
|
|
138
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
|
|
139
|
+
(table_name,),
|
|
140
|
+
)
|
|
141
|
+
return cursor.fetchone() is not None
|
|
142
|
+
|
|
143
|
+
|
|
129
144
|
# -----------------------
|
|
130
145
|
# CSV Import
|
|
131
146
|
# -----------------------
|
|
132
147
|
|
|
133
148
|
|
|
134
|
-
def
|
|
149
|
+
def import_data(conn, table_name: str, reader: DictReader, merge: bool = False):
|
|
135
150
|
"""
|
|
136
|
-
Import
|
|
137
|
-
Column types inferred automatically.
|
|
151
|
+
Import data in the DictReader into the SQLite3 database at conn
|
|
138
152
|
|
|
139
|
-
:param conn: SQLite database connection to use
|
|
140
|
-
:param table_name:
|
|
141
|
-
:param
|
|
153
|
+
:param conn: SQLite database connection to use
|
|
154
|
+
:param table_name: Name of the table to import into
|
|
155
|
+
:param reader: A Dictreader object to import from
|
|
156
|
+
:param merge: (optional) If True, merge into existing table. Defaults to False.
|
|
142
157
|
"""
|
|
143
|
-
if not csv_path.exists():
|
|
144
|
-
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
145
158
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
reader = list(DictReader(f))
|
|
149
|
-
if not reader:
|
|
150
|
-
raise ValueError("CSV file is empty.")
|
|
151
|
-
inferred_cols = infer_columns_from_rows(reader)
|
|
152
|
-
|
|
153
|
-
cursor = conn.cursor()
|
|
159
|
+
cursor = conn.cursor()
|
|
160
|
+
if not merge or not table_exists(cursor, table_name):
|
|
154
161
|
# Drop existing table
|
|
155
162
|
cursor.execute(f'DROP TABLE IF EXISTS "{table_name}";')
|
|
156
|
-
|
|
163
|
+
inferred_cols = infer_columns_from_rows(reader)
|
|
157
164
|
# Create table
|
|
158
165
|
create_sql = _create_table_from_columns(table_name, inferred_cols)
|
|
159
166
|
cursor.execute(create_sql)
|
|
160
167
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
# Insert rows
|
|
169
|
+
cols = list(reader[0].keys())
|
|
170
|
+
norm_map = {c: _normalize(c) for c in cols}
|
|
171
|
+
colnames = ",".join(f'"{norm_map[c]}"' for c in cols)
|
|
172
|
+
placeholders = ",".join("?" for _ in cols)
|
|
173
|
+
insert_sql = f'INSERT INTO "{table_name}" ({colnames}) VALUES ({placeholders})'
|
|
167
174
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
for row in reader:
|
|
176
|
+
values = [row[c] if row[c] != "" else None for c in cols]
|
|
177
|
+
cursor.execute(insert_sql, values)
|
|
171
178
|
|
|
172
179
|
cursor.close()
|
|
173
180
|
conn.commit()
|
|
174
181
|
|
|
175
182
|
|
|
183
|
+
def import_csv_helper(conn, table_name: str, csv_path: Path, merge: bool = False):
|
|
184
|
+
"""
|
|
185
|
+
Import CSV into database using given cursor
|
|
186
|
+
Column types inferred automatically
|
|
187
|
+
|
|
188
|
+
:param conn: SQLite database connection to use
|
|
189
|
+
:param table_name: Table to import the CSV into
|
|
190
|
+
:param csv_path: Path like path of the CSV file to import
|
|
191
|
+
:param merge: (optional) If True, merge into existing table. Defaults to False.
|
|
192
|
+
"""
|
|
193
|
+
if not csv_path.exists():
|
|
194
|
+
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
195
|
+
|
|
196
|
+
# Read CSV rows
|
|
197
|
+
with csv_path.open(newline="", encoding="utf-8") as f:
|
|
198
|
+
reader = list(DictReader(f))
|
|
199
|
+
if not reader:
|
|
200
|
+
raise ValueError("CSV file is empty.")
|
|
201
|
+
import_data(conn, table_name, reader, merge=merge)
|
|
202
|
+
|
|
203
|
+
|
|
176
204
|
# -----------------------
|
|
177
205
|
# diff generation
|
|
178
206
|
# -----------------------
|
|
@@ -180,7 +208,7 @@ def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
|
180
208
|
|
|
181
209
|
def _diffrow_from_sql_row(row: sqlite3_builtin.Row) -> DiffRow:
|
|
182
210
|
"""
|
|
183
|
-
Convert a sqlite3.Row from generate_sql_diff into DiffRow
|
|
211
|
+
Convert a sqlite3.Row from generate_sql_diff into DiffRow
|
|
184
212
|
Row shape:
|
|
185
213
|
(diff_type, col1, col2, col3, ...)
|
|
186
214
|
|
|
@@ -195,7 +223,7 @@ def _diffrow_from_sql_row(row: sqlite3_builtin.Row) -> DiffRow:
|
|
|
195
223
|
|
|
196
224
|
|
|
197
225
|
def diff_cipher_tables(
|
|
198
|
-
|
|
226
|
+
conn,
|
|
199
227
|
*,
|
|
200
228
|
new_table: str,
|
|
201
229
|
old_table: str,
|
|
@@ -204,7 +232,7 @@ def diff_cipher_tables(
|
|
|
204
232
|
Copy old and new tables from SQLCipher into a single
|
|
205
233
|
in-memory sqlite3 database and diff them there.
|
|
206
234
|
|
|
207
|
-
:param
|
|
235
|
+
:param conn: sqlite connection to the db
|
|
208
236
|
:param new_table: name of the new table for comparison
|
|
209
237
|
:param old_table: name of the old table for comparison
|
|
210
238
|
|
|
@@ -216,8 +244,8 @@ def diff_cipher_tables(
|
|
|
216
244
|
|
|
217
245
|
try:
|
|
218
246
|
for table in (old_table, new_table):
|
|
219
|
-
#
|
|
220
|
-
schema_sql =
|
|
247
|
+
# Clone schema using SQLite itself
|
|
248
|
+
schema_sql = conn.execute(
|
|
221
249
|
"""
|
|
222
250
|
SELECT sql
|
|
223
251
|
FROM sqlite_master
|
|
@@ -232,7 +260,7 @@ def diff_cipher_tables(
|
|
|
232
260
|
plain.execute(schema_sql[0])
|
|
233
261
|
|
|
234
262
|
# 2. Copy data
|
|
235
|
-
rows =
|
|
263
|
+
rows = conn.execute(f"SELECT * FROM {table}")
|
|
236
264
|
cols = [d[0] for d in rows.description]
|
|
237
265
|
|
|
238
266
|
col_list = ", ".join(cols)
|
|
@@ -263,9 +291,9 @@ def _generate_sql_diff(
|
|
|
263
291
|
old_table: str,
|
|
264
292
|
) -> list[sqlite3_builtin.Row]:
|
|
265
293
|
"""
|
|
266
|
-
Generate a diff between two tables using standard SQLite features
|
|
294
|
+
Generate a diff between two tables using standard SQLite features
|
|
267
295
|
|
|
268
|
-
-
|
|
296
|
+
- Uses rowid as the primary key for joining
|
|
269
297
|
- Returned row shape:
|
|
270
298
|
(diff_type, preview_col1, preview_col2, preview_col3, ...)
|
|
271
299
|
|
|
@@ -276,7 +304,7 @@ def _generate_sql_diff(
|
|
|
276
304
|
:return: list of sqlite rows that are changed
|
|
277
305
|
"""
|
|
278
306
|
|
|
279
|
-
#
|
|
307
|
+
# Introspect schema (order-preserving)
|
|
280
308
|
cols_info = conn.execute(f"PRAGMA table_info({new_table})").fetchall()
|
|
281
309
|
|
|
282
310
|
if not cols_info:
|
|
@@ -284,16 +312,20 @@ def _generate_sql_diff(
|
|
|
284
312
|
|
|
285
313
|
cols = [row[1] for row in cols_info]
|
|
286
314
|
|
|
287
|
-
key
|
|
288
|
-
|
|
315
|
+
# Exclude rowid from consideration for key or data
|
|
316
|
+
main_cols = [c for c in cols if c != "rowid"]
|
|
289
317
|
|
|
290
|
-
#
|
|
318
|
+
# First column is key, others are for comparison
|
|
319
|
+
key = main_cols[0]
|
|
320
|
+
non_key_cols = main_cols[1:]
|
|
321
|
+
|
|
322
|
+
# Preview columns (key first, then others for readability)
|
|
291
323
|
preview_cols = [key] + non_key_cols[:5]
|
|
292
324
|
|
|
293
325
|
new_preview = ", ".join(f"n.{c}" for c in preview_cols)
|
|
294
326
|
old_preview = ", ".join(f"o.{c}" for c in preview_cols)
|
|
295
327
|
|
|
296
|
-
#
|
|
328
|
+
# Row-value comparison (NULL-safe)
|
|
297
329
|
if non_key_cols:
|
|
298
330
|
changed_predicate = (
|
|
299
331
|
f"({', '.join(f'n.{c}' for c in non_key_cols)}) "
|
|
@@ -333,3 +365,39 @@ def _generate_sql_diff(
|
|
|
333
365
|
"""
|
|
334
366
|
|
|
335
367
|
return list(conn.execute(sql))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def download_csv_helper(
|
|
371
|
+
conn, table_name: str, url: str, session: requests.Session, merge: bool = False
|
|
372
|
+
) -> IO[str]:
|
|
373
|
+
"""
|
|
374
|
+
Download url into a StringIO file object using streaming
|
|
375
|
+
and import into database
|
|
376
|
+
|
|
377
|
+
:param conn: The SQLite3 database connection to use
|
|
378
|
+
:param table_name: The name of the table to import it into
|
|
379
|
+
:param url: URL of the csv to download
|
|
380
|
+
:param session: A requests session to use for the download
|
|
381
|
+
:param merge: (optional) If True, merge into existing table. Defaults to False.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
print(f"☁️ Downloading from: {url}")
|
|
385
|
+
|
|
386
|
+
# Enable streaming
|
|
387
|
+
with session.get(url, stream=True) as resp:
|
|
388
|
+
resp.raise_for_status()
|
|
389
|
+
|
|
390
|
+
# Initialize the string buffer
|
|
391
|
+
string_buffer = StringIO()
|
|
392
|
+
|
|
393
|
+
# Stream decoded text
|
|
394
|
+
# decode_unicode=True uses the encoding from the response headers
|
|
395
|
+
for chunk in resp.iter_content(chunk_size=8192, decode_unicode=True):
|
|
396
|
+
if chunk:
|
|
397
|
+
string_buffer.write(chunk)
|
|
398
|
+
|
|
399
|
+
# Reset pointer to the beginning for DictReader
|
|
400
|
+
string_buffer.seek(0)
|
|
401
|
+
|
|
402
|
+
print(f"✅ Downloaded with encoding {resp.encoding}.")
|
|
403
|
+
import_data(conn, table_name, list(DictReader(string_buffer)), merge=merge)
|
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,63 @@ 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
|
+
|
|
66
|
+
def member_type_count(self, membership_type: str):
|
|
67
|
+
"""
|
|
68
|
+
Count members by membership type string
|
|
69
|
+
|
|
70
|
+
:param membership_type: the string to match, can use percent to match
|
|
71
|
+
remaining or preceeding text
|
|
72
|
+
- Full (match only Full)
|
|
73
|
+
- Full% (match Full and any words after)
|
|
74
|
+
- %Full% ( match Full in the middle)
|
|
75
|
+
"""
|
|
76
|
+
query = "WHERE membership LIKE ?"
|
|
77
|
+
return self.run_count_query(query, (f"{membership_type}",))
|
|
78
|
+
|
|
36
79
|
def get_number_first_last(
|
|
37
80
|
self, first_name: str, last_name: str, found_error: bool = False
|
|
38
81
|
) -> Optional[int]:
|
|
39
82
|
"""
|
|
40
|
-
Find a member number based on first and last name (case-insensitive)
|
|
83
|
+
Find a member number based on first and last name (case-insensitive)
|
|
41
84
|
|
|
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
|
|
85
|
+
:param first_name: First name of the member
|
|
86
|
+
:param last_name: Last name of the member
|
|
87
|
+
:param found_error: (optional): If True, raises ValueError if not found
|
|
45
88
|
|
|
46
|
-
:return: The member number if found, otherwise None
|
|
89
|
+
:return: The member number if found, otherwise None
|
|
47
90
|
|
|
48
|
-
:raises ValueError: If not found and `found_error` is True
|
|
91
|
+
:raises ValueError: If not found and `found_error` is True
|
|
49
92
|
"""
|
|
50
93
|
sql = f"""
|
|
51
94
|
SELECT "member_number"
|
|
@@ -54,6 +97,12 @@ class Member(MojoSkel):
|
|
|
54
97
|
"""
|
|
55
98
|
self.cursor.execute(sql, (first_name, last_name))
|
|
56
99
|
result = self.cursor.fetchone()
|
|
100
|
+
if self.debug:
|
|
101
|
+
print("Sql:")
|
|
102
|
+
pprint(sql)
|
|
103
|
+
print(f"First Name: {first_name} Last Name: {last_name}")
|
|
104
|
+
print("Result:")
|
|
105
|
+
pprint(result[0])
|
|
57
106
|
|
|
58
107
|
if not result and found_error:
|
|
59
108
|
raise ValueError(
|
|
@@ -64,18 +113,22 @@ class Member(MojoSkel):
|
|
|
64
113
|
|
|
65
114
|
def get_number(self, full_name: str, found_error: bool = False) -> Optional[int]:
|
|
66
115
|
"""
|
|
67
|
-
Find a member number by passed full_name
|
|
116
|
+
Find a member number by passed full_name
|
|
68
117
|
Tries first and last, and then middle last if 3 words,
|
|
69
|
-
Then initial of first name if initials passed
|
|
118
|
+
Then initial of first name if initials passed
|
|
119
|
+
Finnaly a fuzzy lookup is tried
|
|
70
120
|
|
|
71
|
-
:param full_name: Full name of the member
|
|
72
|
-
:param found_error: (optional) Raise ValueError if not found
|
|
121
|
+
:param full_name: Full name of the member
|
|
122
|
+
:param found_error: (optional) Raise ValueError if not found
|
|
73
123
|
|
|
74
|
-
:return: Member number if found, else None
|
|
124
|
+
:return: Member number if found, else None
|
|
75
125
|
|
|
76
|
-
:raises ValueError: If not found and `found_error` is True
|
|
126
|
+
:raises ValueError: If not found and `found_error` is True
|
|
77
127
|
"""
|
|
78
|
-
result = self.get_mojo_name(full_name
|
|
128
|
+
result = self.get_mojo_name(full_name)
|
|
129
|
+
if not result:
|
|
130
|
+
result = self.get_fuzz_name(full_name, found_error)
|
|
131
|
+
|
|
79
132
|
if result:
|
|
80
133
|
return self.get_number_first_last(result[0], result[1])
|
|
81
134
|
return None
|
|
@@ -123,7 +176,7 @@ class Member(MojoSkel):
|
|
|
123
176
|
self, full_name: str, found_error: bool = False
|
|
124
177
|
) -> Optional[tuple]:
|
|
125
178
|
"""
|
|
126
|
-
Resolve a member name from a free-text full name
|
|
179
|
+
Resolve a member name from a free-text full name
|
|
127
180
|
|
|
128
181
|
**Search order**
|
|
129
182
|
|
|
@@ -132,14 +185,14 @@ class Member(MojoSkel):
|
|
|
132
185
|
3. initial 1st letter + last
|
|
133
186
|
4. initial 2nd letter + last (for two-letter initials)
|
|
134
187
|
|
|
135
|
-
Returns (first_name, last_name) or None
|
|
188
|
+
Returns (first_name, last_name) or None
|
|
136
189
|
|
|
137
|
-
:param full_name: Full name of the member to find
|
|
138
|
-
:param found_error: (optional) Raise ValueError if not found
|
|
190
|
+
:param full_name: Full name of the member to find
|
|
191
|
+
:param found_error: (optional) Raise ValueError if not found
|
|
139
192
|
|
|
140
|
-
:return: Membermojo name if found, else None
|
|
193
|
+
:return: Membermojo name if found, else None
|
|
141
194
|
|
|
142
|
-
:raises ValueError: If not found and `found_error` is True
|
|
195
|
+
:raises ValueError: If not found and `found_error` is True
|
|
143
196
|
"""
|
|
144
197
|
|
|
145
198
|
parts = full_name.strip().split()
|
|
@@ -207,11 +260,11 @@ class Member(MojoSkel):
|
|
|
207
260
|
|
|
208
261
|
def get_first_last_name(self, member_number: int) -> Optional[str]:
|
|
209
262
|
"""
|
|
210
|
-
Get full name for a given member number
|
|
263
|
+
Get full name for a given member number
|
|
211
264
|
|
|
212
|
-
:param member_number: Member number to look up
|
|
265
|
+
:param member_number: Member number to look up
|
|
213
266
|
|
|
214
|
-
:return: Full name as tuple, or None if not found
|
|
267
|
+
:return: Full name as tuple, or None if not found
|
|
215
268
|
"""
|
|
216
269
|
sql = f"""
|
|
217
270
|
SELECT "first_name", "last_name"
|
|
@@ -225,11 +278,11 @@ class Member(MojoSkel):
|
|
|
225
278
|
|
|
226
279
|
def get_name(self, member_number: int) -> Optional[str]:
|
|
227
280
|
"""
|
|
228
|
-
Get full name for a given member number
|
|
281
|
+
Get full name for a given member number
|
|
229
282
|
|
|
230
|
-
:param member_number: Member number to look up
|
|
283
|
+
:param member_number: Member number to look up
|
|
231
284
|
|
|
232
|
-
:return: Full name as "First Last", or None if not found
|
|
285
|
+
:return: Full name as "First Last", or None if not found
|
|
233
286
|
"""
|
|
234
287
|
|
|
235
288
|
result = self.get_first_last_name(member_number)
|
|
@@ -238,3 +291,39 @@ class Member(MojoSkel):
|
|
|
238
291
|
first_name, last_name = result
|
|
239
292
|
return f"{first_name} {last_name}"
|
|
240
293
|
return None
|
|
294
|
+
|
|
295
|
+
def get_fuzz_name(self, name: str, found_error: bool = False):
|
|
296
|
+
"""
|
|
297
|
+
Fuzzy search for members by name using partial matching
|
|
298
|
+
Searches across first_name and last_name fields
|
|
299
|
+
|
|
300
|
+
:param name: Free text name to search for (partial match)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
:return: Tuple of (first_name, last_name) or None
|
|
304
|
+
|
|
305
|
+
:raises ValueError: If not found and `found_error` is True
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
name = name.strip().lower()
|
|
309
|
+
|
|
310
|
+
# Get all members
|
|
311
|
+
self.cursor.execute(
|
|
312
|
+
f'SELECT *, LOWER(first_name || " " || last_name) AS full FROM "{self.table_name}"'
|
|
313
|
+
)
|
|
314
|
+
rows = self.cursor.fetchall()
|
|
315
|
+
|
|
316
|
+
choices = [row["full"] for row in rows]
|
|
317
|
+
matches = get_close_matches(name, choices, n=1, cutoff=0.7)
|
|
318
|
+
|
|
319
|
+
if not matches:
|
|
320
|
+
if found_error:
|
|
321
|
+
raise ValueError(
|
|
322
|
+
f"❌ Cannot find {name} in member database with fuzzy match."
|
|
323
|
+
)
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
match = matches[0]
|
|
327
|
+
# return the sqlite row for the best match
|
|
328
|
+
row = next(r for r in rows if r["full"] == match)
|
|
329
|
+
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)
|