memberjojo 1.1__py3-none-any.whl → 2.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 +1 -1
- memberjojo/_version.py +16 -3
- memberjojo/mojo_common.py +183 -11
- memberjojo/mojo_loader.py +140 -0
- memberjojo/mojo_member.py +149 -149
- memberjojo/mojo_transaction.py +8 -213
- {memberjojo-1.1.dist-info → memberjojo-2.0.dist-info}/METADATA +24 -10
- memberjojo-2.0.dist-info/RECORD +11 -0
- memberjojo-1.1.dist-info/RECORD +0 -10
- {memberjojo-1.1.dist-info → memberjojo-2.0.dist-info}/WHEEL +0 -0
- {memberjojo-1.1.dist-info → memberjojo-2.0.dist-info}/licenses/LICENSE +0 -0
memberjojo/__init__.py
CHANGED
memberjojo/_version.py
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
# file generated by setuptools-scm
|
|
2
2
|
# don't change, don't track in version control
|
|
3
3
|
|
|
4
|
-
__all__ = [
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
5
12
|
|
|
6
13
|
TYPE_CHECKING = False
|
|
7
14
|
if TYPE_CHECKING:
|
|
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
|
|
|
9
16
|
from typing import Union
|
|
10
17
|
|
|
11
18
|
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
12
20
|
else:
|
|
13
21
|
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
14
23
|
|
|
15
24
|
version: str
|
|
16
25
|
__version__: str
|
|
17
26
|
__version_tuple__: VERSION_TUPLE
|
|
18
27
|
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
19
30
|
|
|
20
|
-
__version__ = version = '
|
|
21
|
-
__version_tuple__ = version_tuple = (
|
|
31
|
+
__version__ = version = '2.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
memberjojo/mojo_common.py
CHANGED
|
@@ -5,7 +5,16 @@ This module provides a common base class (`MojoSkel`) for other `memberjojo` mod
|
|
|
5
5
|
It includes helper methods for working with SQLite databases.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# pylint: disable=no-member
|
|
9
|
+
|
|
10
|
+
from dataclasses import make_dataclass
|
|
11
|
+
from decimal import Decimal, InvalidOperation
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Union, List
|
|
14
|
+
|
|
15
|
+
from sqlcipher3 import dbapi2 as sqlite3
|
|
16
|
+
|
|
17
|
+
from . import mojo_loader
|
|
9
18
|
|
|
10
19
|
|
|
11
20
|
class MojoSkel:
|
|
@@ -14,7 +23,7 @@ class MojoSkel:
|
|
|
14
23
|
for querying tables.
|
|
15
24
|
"""
|
|
16
25
|
|
|
17
|
-
def __init__(self, db_path: str, table_name: str):
|
|
26
|
+
def __init__(self, db_path: str, db_key: str, table_name: str):
|
|
18
27
|
"""
|
|
19
28
|
Initialize the MojoSkel class.
|
|
20
29
|
|
|
@@ -22,13 +31,103 @@ class MojoSkel:
|
|
|
22
31
|
dictionary-style access to columns.
|
|
23
32
|
|
|
24
33
|
:param db_path: Path to the SQLite database file.
|
|
34
|
+
:param db_key: key to unlock the encrypted sqlite database, or encrypt new one.
|
|
25
35
|
:param table_name: Name of the table to operate on, or create when importing.
|
|
26
36
|
"""
|
|
27
|
-
self.
|
|
37
|
+
self.db_path = db_path
|
|
38
|
+
self.table_name = table_name
|
|
39
|
+
self.db_key = db_key
|
|
28
40
|
|
|
41
|
+
# Open connection
|
|
42
|
+
self.conn = sqlite3.connect(self.db_path)
|
|
29
43
|
self.conn.row_factory = sqlite3.Row
|
|
30
44
|
self.cursor = self.conn.cursor()
|
|
31
|
-
|
|
45
|
+
|
|
46
|
+
# Apply SQLCipher key
|
|
47
|
+
self.cursor.execute(f"PRAGMA key='{db_key}'")
|
|
48
|
+
self.cursor.execute("PRAGMA cipher_compatibility = 4")
|
|
49
|
+
print("Cipher:", self.cursor.execute("PRAGMA cipher_version;").fetchone()[0])
|
|
50
|
+
print(f"Encrypted database {self.db_path} loaded securely.")
|
|
51
|
+
|
|
52
|
+
# After table exists (or after import), build the dataclass
|
|
53
|
+
if self.table_exists(table_name):
|
|
54
|
+
self.row_class = self._build_dataclass_from_table()
|
|
55
|
+
else:
|
|
56
|
+
self.row_class = None
|
|
57
|
+
|
|
58
|
+
def __iter__(self):
|
|
59
|
+
"""
|
|
60
|
+
Allow iterating over the class, by outputing all members.
|
|
61
|
+
"""
|
|
62
|
+
if not self.row_class:
|
|
63
|
+
raise RuntimeError("Table not loaded yet — no dataclass available")
|
|
64
|
+
return self._iter_rows()
|
|
65
|
+
|
|
66
|
+
def _iter_rows(self):
|
|
67
|
+
"""
|
|
68
|
+
Iterate over table rows and yield dynamically-created dataclass objects.
|
|
69
|
+
Converts REAL columns to Decimal automatically.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
sql = f'SELECT * FROM "{self.table_name}"'
|
|
73
|
+
|
|
74
|
+
cur = self.conn.cursor()
|
|
75
|
+
cur.execute(sql)
|
|
76
|
+
|
|
77
|
+
for row in cur.fetchall():
|
|
78
|
+
row_dict = dict(row)
|
|
79
|
+
|
|
80
|
+
# Convert REAL → Decimal
|
|
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):
|
|
94
|
+
"""
|
|
95
|
+
Dynamically create a dataclass from the table schema.
|
|
96
|
+
INTEGER → int
|
|
97
|
+
REAL → Decimal
|
|
98
|
+
TEXT → str
|
|
99
|
+
|
|
100
|
+
:return: A dataclass built from the table columns and types.
|
|
101
|
+
"""
|
|
102
|
+
self.cursor.execute(f'PRAGMA table_info("{self.table_name}")')
|
|
103
|
+
cols = self.cursor.fetchall()
|
|
104
|
+
|
|
105
|
+
if not cols:
|
|
106
|
+
raise ValueError(f"Table '{self.table_name}' does not exist")
|
|
107
|
+
|
|
108
|
+
fields = []
|
|
109
|
+
for _cid, name, col_type, _notnull, _dflt, _pk in cols:
|
|
110
|
+
t = col_type.upper()
|
|
111
|
+
|
|
112
|
+
if t.startswith("INT"):
|
|
113
|
+
py_type = int
|
|
114
|
+
elif t.startswith("REAL") or t.startswith("NUM") or t.startswith("DEC"):
|
|
115
|
+
py_type = Decimal
|
|
116
|
+
else:
|
|
117
|
+
py_type = str
|
|
118
|
+
|
|
119
|
+
fields.append((name, py_type))
|
|
120
|
+
|
|
121
|
+
return make_dataclass(f"{self.table_name}_Row", fields)
|
|
122
|
+
|
|
123
|
+
def import_csv(self, csv_path: Path):
|
|
124
|
+
"""
|
|
125
|
+
import the passed CSV into the sqlite database
|
|
126
|
+
|
|
127
|
+
:param csv_path: Path like path of csv file.
|
|
128
|
+
"""
|
|
129
|
+
mojo_loader.import_csv_helper(self.conn, self.table_name, csv_path)
|
|
130
|
+
self.row_class = self._build_dataclass_from_table()
|
|
32
131
|
|
|
33
132
|
def show_table(self, limit: int = 2):
|
|
34
133
|
"""
|
|
@@ -36,10 +135,11 @@ class MojoSkel:
|
|
|
36
135
|
|
|
37
136
|
:param limit: (optional) Number of rows to display. Defaults to 2.
|
|
38
137
|
"""
|
|
39
|
-
self.
|
|
40
|
-
|
|
138
|
+
if self.table_exists(self.table_name):
|
|
139
|
+
self.cursor.execute(f'SELECT * FROM "{self.table_name}" LIMIT ?', (limit,))
|
|
140
|
+
rows = self.cursor.fetchall()
|
|
41
141
|
|
|
42
|
-
|
|
142
|
+
else:
|
|
43
143
|
print("(No data)")
|
|
44
144
|
return
|
|
45
145
|
|
|
@@ -48,8 +148,80 @@ class MojoSkel:
|
|
|
48
148
|
|
|
49
149
|
def count(self) -> int:
|
|
50
150
|
"""
|
|
51
|
-
|
|
151
|
+
:return: count of the number of rows in the table, or 0 if no table.
|
|
152
|
+
"""
|
|
153
|
+
if self.table_exists(self.table_name):
|
|
154
|
+
self.cursor.execute(f'SELECT COUNT(*) FROM "{self.table_name}"')
|
|
155
|
+
result = self.cursor.fetchone()
|
|
156
|
+
return result[0] if result else 0
|
|
157
|
+
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
def get_row(self, entry_name: str, entry_value: str) -> dict:
|
|
161
|
+
"""
|
|
162
|
+
Retrieve a single row matching column = value (case-insensitive).
|
|
163
|
+
|
|
164
|
+
:param entry_name: Column name to filter by.
|
|
165
|
+
:param entry_value: Value to match.
|
|
166
|
+
|
|
167
|
+
:return: The matching row as a dictionary, or None if not found.
|
|
168
|
+
"""
|
|
169
|
+
if not entry_value:
|
|
170
|
+
return None
|
|
171
|
+
query = (
|
|
172
|
+
f'SELECT * FROM "{self.table_name}" WHERE LOWER("{entry_name}") = LOWER(?)'
|
|
173
|
+
)
|
|
174
|
+
self.cursor.execute(query, (entry_value,))
|
|
175
|
+
row = self.cursor.fetchone()
|
|
176
|
+
return dict(row) if row else None
|
|
177
|
+
|
|
178
|
+
def get_row_multi(
|
|
179
|
+
self, match_dict: dict, only_one: bool = True
|
|
180
|
+
) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
|
|
181
|
+
"""
|
|
182
|
+
Retrieve one or many rows matching multiple column=value pairs.
|
|
183
|
+
|
|
184
|
+
:param match_dict: Dictionary of column names and values to match.
|
|
185
|
+
:param only_one: If True (default), return the first matching row.
|
|
186
|
+
If False, return a list of all matching rows.
|
|
187
|
+
|
|
188
|
+
:return:
|
|
189
|
+
- If only_one=True → a single sqlite3.Row or None
|
|
190
|
+
- If only_one=False → list of sqlite3.Row (may be empty)
|
|
191
|
+
"""
|
|
192
|
+
conditions = []
|
|
193
|
+
values = []
|
|
194
|
+
|
|
195
|
+
for col, val in match_dict.items():
|
|
196
|
+
if val is None or val == "":
|
|
197
|
+
conditions.append(f'"{col}" IS NULL')
|
|
198
|
+
else:
|
|
199
|
+
conditions.append(f'"{col}" = ?')
|
|
200
|
+
values.append(
|
|
201
|
+
float(val.quantize(Decimal("0.01")))
|
|
202
|
+
if isinstance(val, Decimal)
|
|
203
|
+
else val
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
base_query = (
|
|
207
|
+
f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)}'
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if only_one:
|
|
211
|
+
query = base_query + " LIMIT 1"
|
|
212
|
+
self.cursor.execute(query, values)
|
|
213
|
+
return self.cursor.fetchone()
|
|
214
|
+
|
|
215
|
+
# Return *all* rows
|
|
216
|
+
self.cursor.execute(base_query, values)
|
|
217
|
+
return self.cursor.fetchall()
|
|
218
|
+
|
|
219
|
+
def table_exists(self, table_name: str) -> bool:
|
|
220
|
+
"""
|
|
221
|
+
Return true or false if a table exists
|
|
52
222
|
"""
|
|
53
|
-
self.cursor.execute(
|
|
54
|
-
|
|
55
|
-
|
|
223
|
+
self.cursor.execute(
|
|
224
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
|
|
225
|
+
(table_name,),
|
|
226
|
+
)
|
|
227
|
+
return self.cursor.fetchone() is not None
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Helper module for importing a CSV into a SQLite database.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from csv import DictReader
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
from collections import defaultdict, Counter
|
|
10
|
+
|
|
11
|
+
# -----------------------
|
|
12
|
+
# Normalization & Type Guessing
|
|
13
|
+
# -----------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize(name: str) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Normalize a column name: lowercase, remove symbols, convert to snake case.
|
|
19
|
+
|
|
20
|
+
:param name: Raw name to normalize.
|
|
21
|
+
|
|
22
|
+
:return: Normalized lowercase string in snake case with no symbols.
|
|
23
|
+
"""
|
|
24
|
+
name = name.strip().lower()
|
|
25
|
+
name = re.sub(r"[^a-z0-9]+", "_", name)
|
|
26
|
+
return name.strip("_")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _guess_type(value: any) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Guess SQLite data type of a CSV value: 'INTEGER', 'REAL', or 'TEXT'.
|
|
32
|
+
"""
|
|
33
|
+
if value is None:
|
|
34
|
+
return "TEXT"
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
value = value.strip()
|
|
37
|
+
if value == "":
|
|
38
|
+
return "TEXT"
|
|
39
|
+
try:
|
|
40
|
+
int(value)
|
|
41
|
+
return "INTEGER"
|
|
42
|
+
except (ValueError, TypeError):
|
|
43
|
+
try:
|
|
44
|
+
float(value)
|
|
45
|
+
return "REAL"
|
|
46
|
+
except (ValueError, TypeError):
|
|
47
|
+
return "TEXT"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def infer_columns_from_rows(rows: list[dict]) -> dict[str, str]:
|
|
51
|
+
"""
|
|
52
|
+
Infer column types from CSV rows.
|
|
53
|
+
Returns mapping: normalized column name -> SQLite type.
|
|
54
|
+
"""
|
|
55
|
+
type_counters = defaultdict(Counter)
|
|
56
|
+
|
|
57
|
+
for row in rows:
|
|
58
|
+
for key, value in row.items():
|
|
59
|
+
norm_key = _normalize(key)
|
|
60
|
+
type_counters[norm_key][_guess_type(value)] += 1
|
|
61
|
+
|
|
62
|
+
inferred_cols = {}
|
|
63
|
+
for col, counter in type_counters.items():
|
|
64
|
+
if counter["TEXT"] == 0:
|
|
65
|
+
if counter["REAL"] > 0:
|
|
66
|
+
inferred_cols[col] = "REAL"
|
|
67
|
+
else:
|
|
68
|
+
inferred_cols[col] = "INTEGER"
|
|
69
|
+
else:
|
|
70
|
+
inferred_cols[col] = "TEXT"
|
|
71
|
+
return inferred_cols
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -----------------------
|
|
75
|
+
# Table Creation
|
|
76
|
+
# -----------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Generate CREATE TABLE SQL from column type mapping.
|
|
82
|
+
|
|
83
|
+
:param table_name: Table to use when creating columns.
|
|
84
|
+
:param columns: dict of columns to create.
|
|
85
|
+
|
|
86
|
+
:return: SQL commands to create the table.
|
|
87
|
+
"""
|
|
88
|
+
column_defs = [f'"{col}" {col_type}' for col, col_type in columns.items()]
|
|
89
|
+
return (
|
|
90
|
+
f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n'
|
|
91
|
+
+ ",\n".join(column_defs)
|
|
92
|
+
+ "\n)"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# -----------------------
|
|
97
|
+
# CSV Import
|
|
98
|
+
# -----------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
102
|
+
"""
|
|
103
|
+
Import CSV into database using given cursor.
|
|
104
|
+
Column types inferred automatically.
|
|
105
|
+
|
|
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.
|
|
109
|
+
"""
|
|
110
|
+
if not csv_path.exists():
|
|
111
|
+
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
112
|
+
|
|
113
|
+
# Read CSV rows
|
|
114
|
+
with csv_path.open(newline="", encoding="utf-8") as f:
|
|
115
|
+
reader = list(DictReader(f))
|
|
116
|
+
if not reader:
|
|
117
|
+
raise ValueError("CSV file is empty.")
|
|
118
|
+
inferred_cols = infer_columns_from_rows(reader)
|
|
119
|
+
|
|
120
|
+
cursor = conn.cursor()
|
|
121
|
+
# Drop existing table
|
|
122
|
+
cursor.execute(f'DROP TABLE IF EXISTS "{table_name}";')
|
|
123
|
+
|
|
124
|
+
# Create table
|
|
125
|
+
create_sql = _create_table_from_columns(table_name, inferred_cols)
|
|
126
|
+
cursor.execute(create_sql)
|
|
127
|
+
|
|
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
|
+
|
|
135
|
+
for row in reader:
|
|
136
|
+
values = [row[c] if row[c] != "" else None for c in cols]
|
|
137
|
+
cursor.execute(insert_sql, values)
|
|
138
|
+
|
|
139
|
+
cursor.close()
|
|
140
|
+
conn.commit()
|
memberjojo/mojo_member.py
CHANGED
|
@@ -5,37 +5,11 @@ This module loads data from a `members.csv` file downloaded from Membermojo,
|
|
|
5
5
|
stores it in SQLite, and provides helper functions for member lookups.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from csv import DictReader
|
|
9
|
-
from dataclasses import dataclass
|
|
10
8
|
from pathlib import Path
|
|
11
9
|
from typing import Optional
|
|
12
|
-
import sqlite3
|
|
13
|
-
from .config import CSV_ENCODING # import encoding from config.py
|
|
14
10
|
from .mojo_common import MojoSkel
|
|
15
11
|
|
|
16
12
|
|
|
17
|
-
@dataclass
|
|
18
|
-
class MemberData:
|
|
19
|
-
"""
|
|
20
|
-
A dataclass to represent a single member's data for database operations.
|
|
21
|
-
|
|
22
|
-
Attributes:
|
|
23
|
-
member_num (int): Unique member number (primary key).
|
|
24
|
-
title (str): Title (e.g., Mr, Mrs, Ms).
|
|
25
|
-
first_name (str): Member's first name.
|
|
26
|
-
last_name (str): Member's last name.
|
|
27
|
-
membermojo_id (int): Unique Membermojo ID.
|
|
28
|
-
short_url (str): Short URL to Membermojo profile.
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
member_num: int
|
|
32
|
-
title: str
|
|
33
|
-
first_name: str
|
|
34
|
-
last_name: str
|
|
35
|
-
membermojo_id: int
|
|
36
|
-
short_url: str
|
|
37
|
-
|
|
38
|
-
|
|
39
13
|
class Member(MojoSkel):
|
|
40
14
|
"""
|
|
41
15
|
Subclass of MojoSkel providing member-specific database functions.
|
|
@@ -45,53 +19,19 @@ class Member(MojoSkel):
|
|
|
45
19
|
|
|
46
20
|
:param member_db_path (Path): Path to the SQLite database file.
|
|
47
21
|
:param table_name (str): (optional) Table name to use. Defaults to "members".
|
|
22
|
+
:param db_key: (optional) key to unlock the encrypted sqlite database, unencrypted if unset.
|
|
48
23
|
"""
|
|
49
24
|
|
|
50
|
-
def __init__(
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
member_db_path: Path,
|
|
28
|
+
db_key: str,
|
|
29
|
+
table_name: str = "members",
|
|
30
|
+
):
|
|
51
31
|
"""
|
|
52
32
|
Initialize the Member database handler.
|
|
53
33
|
"""
|
|
54
|
-
super().__init__(member_db_path, table_name)
|
|
55
|
-
|
|
56
|
-
def __iter__(self):
|
|
57
|
-
"""
|
|
58
|
-
Allow iterating over the class, by outputing all members.
|
|
59
|
-
"""
|
|
60
|
-
sql = (
|
|
61
|
-
f"SELECT member_number, "
|
|
62
|
-
f"title, "
|
|
63
|
-
f"first_name, "
|
|
64
|
-
f"last_name, "
|
|
65
|
-
f"membermojo_id, "
|
|
66
|
-
f"short_url "
|
|
67
|
-
f'FROM "{self.table_name}"'
|
|
68
|
-
)
|
|
69
|
-
self.cursor.execute(sql)
|
|
70
|
-
rows = self.cursor.fetchall()
|
|
71
|
-
for row in rows:
|
|
72
|
-
yield MemberData(*row)
|
|
73
|
-
|
|
74
|
-
def _create_tables(self):
|
|
75
|
-
"""
|
|
76
|
-
Create the members table in the database if it doesn't exist.
|
|
77
|
-
|
|
78
|
-
The table includes member number, title, first/last names,
|
|
79
|
-
Membermojo ID, and a short profile URL.
|
|
80
|
-
"""
|
|
81
|
-
sql_statements = [
|
|
82
|
-
f"""CREATE TABLE IF NOT EXISTS "{self.table_name}" (
|
|
83
|
-
member_number INTEGER PRIMARY KEY,
|
|
84
|
-
title TEXT NOT NULL CHECK(title IN ('Dr', 'Mr', 'Mrs', 'Miss', 'Ms')),
|
|
85
|
-
first_name TEXT NOT NULL,
|
|
86
|
-
last_name TEXT NOT NULL,
|
|
87
|
-
membermojo_id INTEGER UNIQUE NOT NULL,
|
|
88
|
-
short_url TEXT NOT NULL
|
|
89
|
-
);"""
|
|
90
|
-
]
|
|
91
|
-
|
|
92
|
-
for statement in sql_statements:
|
|
93
|
-
self.cursor.execute(statement)
|
|
94
|
-
self.conn.commit()
|
|
34
|
+
super().__init__(member_db_path, db_key, table_name)
|
|
95
35
|
|
|
96
36
|
def get_number_first_last(
|
|
97
37
|
self, first_name: str, last_name: str, found_error: bool = False
|
|
@@ -108,9 +48,9 @@ class Member(MojoSkel):
|
|
|
108
48
|
:raises ValueError: If not found and `found_error` is True.
|
|
109
49
|
"""
|
|
110
50
|
sql = f"""
|
|
111
|
-
SELECT member_number
|
|
51
|
+
SELECT "member_number"
|
|
112
52
|
FROM "{self.table_name}"
|
|
113
|
-
WHERE LOWER(first_name) = LOWER(?) AND LOWER(last_name) = LOWER(?)
|
|
53
|
+
WHERE LOWER("first_name") = LOWER(?) AND LOWER("last_name") = LOWER(?)
|
|
114
54
|
"""
|
|
115
55
|
self.cursor.execute(sql, (first_name, last_name))
|
|
116
56
|
result = self.cursor.fetchone()
|
|
@@ -124,7 +64,9 @@ class Member(MojoSkel):
|
|
|
124
64
|
|
|
125
65
|
def get_number(self, full_name: str, found_error: bool = False) -> Optional[int]:
|
|
126
66
|
"""
|
|
127
|
-
Find a member number by
|
|
67
|
+
Find a member number by passed full_name.
|
|
68
|
+
Tries first and last, and then middle last if 3 words,
|
|
69
|
+
Then initial of first name if initials passed.
|
|
128
70
|
|
|
129
71
|
:param full_name: Full name of the member.
|
|
130
72
|
:param found_error: (optional) Raise ValueError if not found.
|
|
@@ -133,105 +75,163 @@ class Member(MojoSkel):
|
|
|
133
75
|
|
|
134
76
|
:raises ValueError: If not found and `found_error` is True.
|
|
135
77
|
"""
|
|
136
|
-
|
|
137
|
-
|
|
78
|
+
result = self.get_mojo_name(full_name, found_error)
|
|
79
|
+
if result:
|
|
80
|
+
return self.get_number_first_last(result[0], result[1])
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def _lookup_exact(self, first_name: str, last_name: str) -> Optional[tuple]:
|
|
84
|
+
"""
|
|
85
|
+
Lookup first_name and last_name in the member database, return found name or none
|
|
86
|
+
|
|
87
|
+
:param first_name: First name to lookup
|
|
88
|
+
:param last_name: Last name to lookup
|
|
89
|
+
|
|
90
|
+
:return: Name on membermojo or None
|
|
91
|
+
"""
|
|
92
|
+
sql = f"""
|
|
93
|
+
SELECT "first_name", "last_name"
|
|
94
|
+
FROM "{self.table_name}"
|
|
95
|
+
WHERE LOWER("first_name") = LOWER(?)
|
|
96
|
+
AND LOWER("last_name") = LOWER(?)
|
|
97
|
+
"""
|
|
98
|
+
self.cursor.execute(sql, (first_name, last_name))
|
|
99
|
+
row = self.cursor.fetchone()
|
|
100
|
+
return (row["first_name"], row["last_name"]) if row else None
|
|
101
|
+
|
|
102
|
+
def _lookup_initial(self, letter: str, last_name: str) -> Optional[tuple]:
|
|
103
|
+
"""
|
|
104
|
+
Lookup Initial and last_name in the member database, return found name or none
|
|
105
|
+
|
|
106
|
+
:param letter: initial to search for
|
|
107
|
+
:param last_name: last name to search for
|
|
108
|
+
|
|
109
|
+
:return: Name on membermojo or None
|
|
110
|
+
"""
|
|
111
|
+
sql = f"""
|
|
112
|
+
SELECT "first_name", "last_name"
|
|
113
|
+
FROM "{self.table_name}"
|
|
114
|
+
WHERE LOWER("first_name") LIKE LOWER(?) || '%'
|
|
115
|
+
AND LOWER("last_name") = LOWER(?)
|
|
116
|
+
LIMIT 1
|
|
117
|
+
"""
|
|
118
|
+
self.cursor.execute(sql, (letter, last_name))
|
|
119
|
+
row = self.cursor.fetchone()
|
|
120
|
+
return (row["first_name"], row["last_name"]) if row else None
|
|
121
|
+
|
|
122
|
+
def get_mojo_name(
|
|
123
|
+
self, full_name: str, found_error: bool = False
|
|
124
|
+
) -> Optional[tuple]:
|
|
125
|
+
"""
|
|
126
|
+
Resolve a member name from a free-text full name.
|
|
127
|
+
Search order:
|
|
128
|
+
1. first + last
|
|
129
|
+
2. middle + last (if three parts)
|
|
130
|
+
3. initial 1st letter + last
|
|
131
|
+
4. initial 2nd letter + last (for two-letter initials)
|
|
132
|
+
Returns (first_name, last_name) or None.
|
|
133
|
+
|
|
134
|
+
:param full_name: Full name of the member to find.
|
|
135
|
+
:param found_error: (optional) Raise ValueError if not found.
|
|
136
|
+
|
|
137
|
+
:return: Membermojo name if found, else None.
|
|
138
|
+
|
|
139
|
+
:raises ValueError: If not found and `found_error` is True.
|
|
140
|
+
"""
|
|
141
|
+
|
|
138
142
|
parts = full_name.strip().split()
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if
|
|
154
|
-
|
|
143
|
+
tried = []
|
|
144
|
+
|
|
145
|
+
# If only one one word passed, fail early
|
|
146
|
+
if len(parts) < 2:
|
|
147
|
+
if found_error:
|
|
148
|
+
raise ValueError(f"❌ Cannot extract name from: {full_name}")
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
# ----------------------------
|
|
152
|
+
# 1. Try direct first + last
|
|
153
|
+
# ----------------------------
|
|
154
|
+
tried.append(f"{parts[0]} {parts[-1]}")
|
|
155
|
+
|
|
156
|
+
result = self._lookup_exact(parts[0], parts[-1])
|
|
157
|
+
if result:
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
# ----------------------------
|
|
161
|
+
# 2. Try middle + last and build initials if no match
|
|
162
|
+
# ----------------------------
|
|
163
|
+
if len(parts) == 3:
|
|
164
|
+
tried.append(f"{parts[1]} {parts[2]}")
|
|
165
|
+
|
|
166
|
+
result = self._lookup_exact(parts[1], parts[2])
|
|
167
|
+
if result:
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
# First letter of first + first letter of middle
|
|
171
|
+
initials = parts[0][0].upper() + parts[1][0].upper()
|
|
172
|
+
else:
|
|
173
|
+
# Only first letter of first name
|
|
174
|
+
initials = parts[0][0].upper()
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------
|
|
177
|
+
# Initial fallback lookups
|
|
178
|
+
# ------------------------------------------------
|
|
179
|
+
|
|
180
|
+
# 3. Try first initial + last name
|
|
181
|
+
first_initial = initials[0]
|
|
182
|
+
tried.append(f"{first_initial} {parts[-1]}")
|
|
183
|
+
result = self._lookup_initial(first_initial, parts[-1])
|
|
184
|
+
if result:
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
# 4. Try second initial + last name (e.g., for JA or AM)
|
|
188
|
+
if len(initials) > 1:
|
|
189
|
+
second_initial = initials[1]
|
|
190
|
+
tried.append(f"{second_initial} {parts[-1]}")
|
|
191
|
+
result = self._lookup_initial(second_initial, parts[-1])
|
|
192
|
+
if result:
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
# ----------------------------
|
|
196
|
+
# 5. No match
|
|
197
|
+
# ----------------------------
|
|
198
|
+
if found_error:
|
|
155
199
|
raise ValueError(
|
|
156
200
|
f"❌ Cannot find {full_name} in member database. Tried: {tried}"
|
|
157
201
|
)
|
|
158
|
-
return member_num
|
|
159
202
|
|
|
160
|
-
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def get_first_last_name(self, member_number: int) -> Optional[str]:
|
|
161
206
|
"""
|
|
162
207
|
Get full name for a given member number.
|
|
163
208
|
|
|
164
209
|
:param member_number: Member number to look up.
|
|
165
210
|
|
|
166
|
-
:return: Full name as
|
|
211
|
+
:return: Full name as tuple, or None if not found.
|
|
167
212
|
"""
|
|
168
213
|
sql = f"""
|
|
169
|
-
SELECT first_name, last_name
|
|
214
|
+
SELECT "first_name", "last_name"
|
|
170
215
|
FROM "{self.table_name}"
|
|
171
|
-
WHERE member_number = ?
|
|
216
|
+
WHERE "member_number" = ?
|
|
172
217
|
"""
|
|
173
218
|
self.cursor.execute(sql, (member_number,))
|
|
174
219
|
result = self.cursor.fetchone()
|
|
175
220
|
|
|
176
|
-
if result
|
|
177
|
-
first_name, last_name = result
|
|
178
|
-
return f"{first_name} {last_name}"
|
|
179
|
-
return None
|
|
221
|
+
return result if result else None
|
|
180
222
|
|
|
181
|
-
def
|
|
182
|
-
"""
|
|
183
|
-
Insert a member into the database if not already present.
|
|
184
|
-
|
|
185
|
-
:param member: The member to add.
|
|
186
|
-
"""
|
|
187
|
-
sql = f"""INSERT OR ABORT INTO "{self.table_name}"
|
|
188
|
-
(member_number, title, first_name, last_name, membermojo_id, short_url)
|
|
189
|
-
VALUES (?, ?, ?, ?, ?, ?)"""
|
|
190
|
-
|
|
191
|
-
try:
|
|
192
|
-
self.cursor.execute(
|
|
193
|
-
sql,
|
|
194
|
-
(
|
|
195
|
-
member.member_num,
|
|
196
|
-
member.title,
|
|
197
|
-
member.first_name,
|
|
198
|
-
member.last_name,
|
|
199
|
-
member.membermojo_id,
|
|
200
|
-
member.short_url,
|
|
201
|
-
),
|
|
202
|
-
)
|
|
203
|
-
self.conn.commit()
|
|
204
|
-
print(
|
|
205
|
-
f"Created user {member.member_num}: {member.first_name} {member.last_name}"
|
|
206
|
-
)
|
|
207
|
-
except sqlite3.IntegrityError:
|
|
208
|
-
pass
|
|
209
|
-
|
|
210
|
-
def import_csv(self, csv_path: Path):
|
|
223
|
+
def get_name(self, member_number: int) -> Optional[str]:
|
|
211
224
|
"""
|
|
212
|
-
|
|
225
|
+
Get full name for a given member number.
|
|
213
226
|
|
|
214
|
-
:param
|
|
227
|
+
:param member_number: Member number to look up.
|
|
215
228
|
|
|
216
|
-
|
|
217
|
-
Only adds members not already in the database (INSERT OR ABORT).
|
|
229
|
+
:return: Full name as "First Last", or None if not found.
|
|
218
230
|
"""
|
|
219
|
-
print(f"Using SQLite database version {sqlite3.sqlite_version}")
|
|
220
|
-
self._create_tables()
|
|
221
231
|
|
|
222
|
-
|
|
223
|
-
with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
|
|
224
|
-
mojo_reader = DictReader(csvfile)
|
|
232
|
+
result = self.get_first_last_name(member_number)
|
|
225
233
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
first_name=row["First name"].strip(),
|
|
231
|
-
last_name=row["Last name"].strip(),
|
|
232
|
-
membermojo_id=int(row["membermojo ID"]),
|
|
233
|
-
short_url=row["Short URL"].strip(),
|
|
234
|
-
)
|
|
235
|
-
self._add(member)
|
|
236
|
-
except FileNotFoundError:
|
|
237
|
-
print(f"CSV file not found: {csv_path}")
|
|
234
|
+
if result:
|
|
235
|
+
first_name, last_name = result
|
|
236
|
+
return f"{first_name} {last_name}"
|
|
237
|
+
return None
|
memberjojo/mojo_transaction.py
CHANGED
|
@@ -5,12 +5,6 @@ Provides automatic column type inference, robust CSV importing, and
|
|
|
5
5
|
helper methods for querying the database.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from collections import Counter, defaultdict
|
|
9
|
-
from csv import DictReader
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from sqlite3 import IntegrityError, OperationalError, DatabaseError
|
|
12
|
-
|
|
13
|
-
from .config import CSV_ENCODING # import encoding from config.py
|
|
14
8
|
from .mojo_common import MojoSkel
|
|
15
9
|
|
|
16
10
|
|
|
@@ -23,215 +17,16 @@ class Transaction(MojoSkel):
|
|
|
23
17
|
|
|
24
18
|
:param payment_db_path: Path to the SQLite database.
|
|
25
19
|
:param table_name: (optional) Name of the table. Defaults to "payments".
|
|
20
|
+
:param db_key: (optional) key to unlock the encrypted sqlite database, unencrypted if unset.
|
|
26
21
|
"""
|
|
27
22
|
|
|
28
|
-
def __init__(
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
payment_db_path: str,
|
|
26
|
+
db_key: str,
|
|
27
|
+
table_name: str = "payments",
|
|
28
|
+
):
|
|
29
29
|
"""
|
|
30
30
|
Initialize the Transaction object.
|
|
31
31
|
"""
|
|
32
|
-
super().__init__(payment_db_path, table_name)
|
|
33
|
-
self.columns = {}
|
|
34
|
-
|
|
35
|
-
def _guess_type(self, value: any) -> str:
|
|
36
|
-
"""
|
|
37
|
-
Guess the SQLite data type of a CSV field value.
|
|
38
|
-
|
|
39
|
-
:param value: The value from a CSV field.
|
|
40
|
-
|
|
41
|
-
:return: One of 'INTEGER', 'REAL', or 'TEXT'.
|
|
42
|
-
"""
|
|
43
|
-
if value is None:
|
|
44
|
-
return "TEXT"
|
|
45
|
-
|
|
46
|
-
if isinstance(value, str):
|
|
47
|
-
value = value.strip()
|
|
48
|
-
if value == "":
|
|
49
|
-
return "TEXT"
|
|
50
|
-
|
|
51
|
-
try:
|
|
52
|
-
int(value)
|
|
53
|
-
return "INTEGER"
|
|
54
|
-
except (ValueError, TypeError):
|
|
55
|
-
try:
|
|
56
|
-
float(value)
|
|
57
|
-
return "REAL"
|
|
58
|
-
except (ValueError, TypeError):
|
|
59
|
-
return "TEXT"
|
|
60
|
-
|
|
61
|
-
def _infer_columns_from_rows(self, rows: list[dict]):
|
|
62
|
-
"""
|
|
63
|
-
Infer SQLite column types based on sample CSV data.
|
|
64
|
-
|
|
65
|
-
:param rows: Sample rows from CSV to analyze.
|
|
66
|
-
"""
|
|
67
|
-
type_counters = defaultdict(Counter)
|
|
68
|
-
|
|
69
|
-
for row in rows:
|
|
70
|
-
for key, value in row.items():
|
|
71
|
-
guessed_type = self._guess_type(value)
|
|
72
|
-
type_counters[key][guessed_type] += 1
|
|
73
|
-
|
|
74
|
-
self.columns = {}
|
|
75
|
-
for col, counter in type_counters.items():
|
|
76
|
-
if counter["TEXT"] == 0:
|
|
77
|
-
if counter["REAL"] > 0:
|
|
78
|
-
self.columns[col] = "REAL"
|
|
79
|
-
else:
|
|
80
|
-
self.columns[col] = "INTEGER"
|
|
81
|
-
else:
|
|
82
|
-
self.columns[col] = "TEXT"
|
|
83
|
-
|
|
84
|
-
print("Inferred columns:", self.columns)
|
|
85
|
-
|
|
86
|
-
def _create_tables(self, table: str, primary_col: str):
|
|
87
|
-
"""
|
|
88
|
-
Create the table if it doesn't exist, using inferred schema.
|
|
89
|
-
|
|
90
|
-
:param table: Table name.
|
|
91
|
-
:param primary_col: Column to use as primary key, or None for default.
|
|
92
|
-
"""
|
|
93
|
-
col_names = list(self.columns.keys())
|
|
94
|
-
if primary_col is None:
|
|
95
|
-
primary_col = col_names[0]
|
|
96
|
-
elif primary_col not in self.columns:
|
|
97
|
-
raise ValueError(f"Primary key column '{primary_col}' not found in CSV.")
|
|
98
|
-
|
|
99
|
-
cols_def = ", ".join(
|
|
100
|
-
f'"{col}" {col_type}{" PRIMARY KEY" if col == primary_col else ""}'
|
|
101
|
-
for col, col_type in self.columns.items()
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
self.cursor.execute(f'CREATE TABLE IF NOT EXISTS "{table}" ({cols_def})')
|
|
105
|
-
self.conn.commit()
|
|
106
|
-
|
|
107
|
-
def _parse_row_values(self, row: dict, column_types: dict) -> tuple:
|
|
108
|
-
"""
|
|
109
|
-
Convert CSV row string values to types suitable for SQLite.
|
|
110
|
-
|
|
111
|
-
:param row: A dictionary from the CSV row.
|
|
112
|
-
:param column_types: Mapping of column names to SQLite types.
|
|
113
|
-
|
|
114
|
-
:return: Parsed values.
|
|
115
|
-
"""
|
|
116
|
-
values = []
|
|
117
|
-
for col, col_type in column_types.items():
|
|
118
|
-
val = row.get(col, "")
|
|
119
|
-
if val is None or val.strip() == "":
|
|
120
|
-
values.append(None)
|
|
121
|
-
elif col_type == "REAL":
|
|
122
|
-
values.append(float(val))
|
|
123
|
-
elif col_type == "INTEGER":
|
|
124
|
-
values.append(int(val))
|
|
125
|
-
else:
|
|
126
|
-
values.append(val)
|
|
127
|
-
return tuple(values)
|
|
128
|
-
|
|
129
|
-
def import_csv(self, csv_path: Path, pk_column: str = None, sample_size: int = 100):
|
|
130
|
-
"""
|
|
131
|
-
Import a completed_payments.csv file into SQLite.
|
|
132
|
-
|
|
133
|
-
Infers column types and logs failed rows. Creates the table if needed.
|
|
134
|
-
|
|
135
|
-
:param csv_path: Path to the CSV file.
|
|
136
|
-
:param pk_column: (optional) Primary key column name. Defaults to the first column.
|
|
137
|
-
:param sample_size: (optional) Number of rows to sample for type inference. Defaults to 100.
|
|
138
|
-
|
|
139
|
-
:raises ValueError: If the CSV is empty or contains failed insertions.
|
|
140
|
-
"""
|
|
141
|
-
try:
|
|
142
|
-
# First pass: infer schema from sample
|
|
143
|
-
with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
|
|
144
|
-
reader = DictReader(csvfile)
|
|
145
|
-
sample_rows = [row for _, row in zip(range(sample_size), reader)]
|
|
146
|
-
if not sample_rows:
|
|
147
|
-
raise ValueError("CSV file is empty.")
|
|
148
|
-
|
|
149
|
-
# Only infer columns if not already set (i.e., first import)
|
|
150
|
-
if not self.columns:
|
|
151
|
-
self._infer_columns_from_rows(sample_rows)
|
|
152
|
-
|
|
153
|
-
except FileNotFoundError:
|
|
154
|
-
print(f"CSV file not found: {csv_path}")
|
|
155
|
-
return
|
|
156
|
-
|
|
157
|
-
# Create table
|
|
158
|
-
self._create_tables(self.table_name, pk_column)
|
|
159
|
-
|
|
160
|
-
# Count rows before import
|
|
161
|
-
count_before = self.count()
|
|
162
|
-
|
|
163
|
-
# Second pass: insert all rows
|
|
164
|
-
failed_rows = []
|
|
165
|
-
with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
|
|
166
|
-
reader = DictReader(csvfile)
|
|
167
|
-
column_list = ", ".join(f'"{col}"' for col in self.columns)
|
|
168
|
-
placeholders = ", ".join(["?"] * len(self.columns))
|
|
169
|
-
insert_stmt = f'INSERT OR IGNORE INTO "{self.table_name}" ({column_list}) \
|
|
170
|
-
VALUES ({placeholders})'
|
|
171
|
-
|
|
172
|
-
for row in reader:
|
|
173
|
-
try:
|
|
174
|
-
self.cursor.execute(
|
|
175
|
-
insert_stmt, self._parse_row_values(row, self.columns)
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
except (
|
|
179
|
-
IntegrityError,
|
|
180
|
-
OperationalError,
|
|
181
|
-
DatabaseError,
|
|
182
|
-
ValueError,
|
|
183
|
-
) as e:
|
|
184
|
-
failed_rows.append((row.copy(), str(e)))
|
|
185
|
-
|
|
186
|
-
self.conn.commit()
|
|
187
|
-
|
|
188
|
-
print(
|
|
189
|
-
f"Inserted {self.count() - count_before} new rows into '{self.table_name}'."
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
if failed_rows:
|
|
193
|
-
print(f"{len(failed_rows)} rows failed to insert:")
|
|
194
|
-
for row, error in failed_rows[:5]:
|
|
195
|
-
print(f"Failed: {error} | Data: {row}")
|
|
196
|
-
if len(failed_rows) > 5:
|
|
197
|
-
print(f"... and {len(failed_rows) - 5} more")
|
|
198
|
-
raise ValueError(f"Failed to import: {csv_path}")
|
|
199
|
-
|
|
200
|
-
def get_row(self, entry_name: str, entry_value: str) -> dict:
|
|
201
|
-
"""
|
|
202
|
-
Retrieve a single row matching column = value (case-insensitive).
|
|
203
|
-
|
|
204
|
-
:param entry_name: Column name to filter by.
|
|
205
|
-
:param entry_value: Value to match.
|
|
206
|
-
|
|
207
|
-
:return: The matching row as a dictionary, or None if not found.
|
|
208
|
-
"""
|
|
209
|
-
if not entry_value:
|
|
210
|
-
return None
|
|
211
|
-
query = (
|
|
212
|
-
f'SELECT * FROM "{self.table_name}" WHERE LOWER("{entry_name}") = LOWER(?)'
|
|
213
|
-
)
|
|
214
|
-
self.cursor.execute(query, (entry_value,))
|
|
215
|
-
row = self.cursor.fetchone()
|
|
216
|
-
return dict(row) if row else None
|
|
217
|
-
|
|
218
|
-
def get_row_multi(self, match_dict: dict) -> dict:
|
|
219
|
-
"""
|
|
220
|
-
Retrieve the first row matching multiple column = value pairs.
|
|
221
|
-
|
|
222
|
-
:param match_dict: Dictionary of column names and values to match.
|
|
223
|
-
|
|
224
|
-
:return: The first matching row, or None if not found.
|
|
225
|
-
"""
|
|
226
|
-
conditions = []
|
|
227
|
-
values = []
|
|
228
|
-
for col, val in match_dict.items():
|
|
229
|
-
if val is None or val == "":
|
|
230
|
-
conditions.append(f'"{col}" IS NULL')
|
|
231
|
-
else:
|
|
232
|
-
conditions.append(f'"{col}" = ?')
|
|
233
|
-
values.append(val)
|
|
234
|
-
|
|
235
|
-
query = f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)} LIMIT 1'
|
|
236
|
-
self.cursor.execute(query, values)
|
|
237
|
-
return self.cursor.fetchone()
|
|
32
|
+
super().__init__(payment_db_path, db_key, table_name)
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memberjojo
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0
|
|
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
|
|
10
11
|
Requires-Dist: coverage ; extra == "dev"
|
|
11
12
|
Requires-Dist: flit ; extra == "dev"
|
|
12
13
|
Requires-Dist: pytest ; extra == "dev"
|
|
@@ -23,16 +24,15 @@ Provides-Extra: lint
|
|
|
23
24
|
|
|
24
25
|
# memberjojo
|
|
25
26
|
|
|
26
|
-
`memberjojo` is a Python library for
|
|
27
|
+
`memberjojo` is a Python library for using [Membermojo](http://membermojo.co.uk/)
|
|
27
28
|
data from CSV imports.\
|
|
28
|
-
It provides member database
|
|
29
|
-
This is done in a local SQLite database, and does not alter
|
|
29
|
+
It provides member database, and completed payments querying.\
|
|
30
|
+
This is done in a local SQLite database which is encrypted, and does not alter
|
|
31
|
+
anything on Membermojo.\
|
|
30
32
|
It provides tools to load, and query membership and transaction data efficiently
|
|
31
33
|
without having to use SQLite directly.\
|
|
32
|
-
When importing CSV files existing entries are
|
|
33
|
-
latest download and the local database is updated
|
|
34
|
-
All the transaction data is imported into the database,
|
|
35
|
-
but currently only a limited amount of member data is imported.
|
|
34
|
+
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
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
@@ -40,7 +40,21 @@ but currently only a limited amount of member data is imported.
|
|
|
40
40
|
|
|
41
41
|
Install via `pip`:
|
|
42
42
|
|
|
43
|
+
Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
|
|
44
|
+
(The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH` is needed
|
|
45
|
+
for 'clang' to be able to find the header files)\
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
brew install sqlcipher
|
|
49
|
+
export C_INCLUDE_PATH="/opt/homebrew/opt/sqlcipher/include"
|
|
50
|
+
export LIBRARY_PATH="/opt/homebrew/opt/sqlcipher/lib"
|
|
51
|
+
pip install memberjojo
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Installing via `pip` on ubuntu:
|
|
55
|
+
|
|
43
56
|
```bash
|
|
57
|
+
sudo apt-get --no-install-recommends --no-install-suggests install libsqlcipher-dev
|
|
44
58
|
pip install memberjojo
|
|
45
59
|
```
|
|
46
60
|
|
|
@@ -64,11 +78,11 @@ from membermojo import Member
|
|
|
64
78
|
member_database_path = Path(Path(__file__).parent, "database", "my-members.db")
|
|
65
79
|
member_csv_path = Path("download", "members.csv")
|
|
66
80
|
|
|
67
|
-
members = Member(member_database_path)
|
|
81
|
+
members = Member(member_database_path, "My DB Password")
|
|
68
82
|
members.import_csv(member_csv_path)
|
|
69
83
|
|
|
70
84
|
for member in members:
|
|
71
|
-
print(member.first_name, member.last_name, member.
|
|
85
|
+
print(member.first_name, member.last_name, member.member_number)
|
|
72
86
|
|
|
73
87
|
# Get full name for a given member number
|
|
74
88
|
found_name = members.get_name(1)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
memberjojo/__init__.py,sha256=JGhIJ1HGbzrwzF4HsopOdP4eweLjUmFjbIDvMbk1MKI,291
|
|
2
|
+
memberjojo/_version.py,sha256=BQP3Alj0qj-vHGLR2CI-B53HBhY5UAffEPyInuYZ0jU,699
|
|
3
|
+
memberjojo/config.py,sha256=_R3uWh5bfMCfZatXLm49pDXet-tipoPbUO7FUeMu2OI,73
|
|
4
|
+
memberjojo/mojo_common.py,sha256=uisTDjc7SCcTIecF35issytUzD8cHn3Tx7YbDZkp64E,7402
|
|
5
|
+
memberjojo/mojo_loader.py,sha256=PZRrdjCCqatrGbqyqPoI5Pg6IWmeVduqyE8zV9GwKlg,3935
|
|
6
|
+
memberjojo/mojo_member.py,sha256=1Tei14y3opJ4To0KerUwJ7_kidFB87jKWZ7KjDN3B4k,8088
|
|
7
|
+
memberjojo/mojo_transaction.py,sha256=EuP_iu5R5HHCkr6PaJdWG0BP-XFMAjl-YPuCDVnKaRE,916
|
|
8
|
+
memberjojo-2.0.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
|
|
9
|
+
memberjojo-2.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
10
|
+
memberjojo-2.0.dist-info/METADATA,sha256=rGQLFERi8EpqH5oowAatO5xIOtaNc987BBOH4DY0zc4,3602
|
|
11
|
+
memberjojo-2.0.dist-info/RECORD,,
|
memberjojo-1.1.dist-info/RECORD
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
memberjojo/__init__.py,sha256=KfW3YTaJkEfdjr1Yf9Lf1jBrBErvCbZ7p-1Y6qzEIOY,303
|
|
2
|
-
memberjojo/_version.py,sha256=AAsnOeBTLGhtISZFJd737cAtw6_DnK8inDZsYGvd-x0,506
|
|
3
|
-
memberjojo/config.py,sha256=_R3uWh5bfMCfZatXLm49pDXet-tipoPbUO7FUeMu2OI,73
|
|
4
|
-
memberjojo/mojo_common.py,sha256=hSMNggC1BOTLOeRQlv4LOC7gqfM0GMRdoHHT5PnuakA,1597
|
|
5
|
-
memberjojo/mojo_member.py,sha256=IuZMvERjmrBoz_rQIr9SBIDEC0PqEPiJX2SvA69paSU,8018
|
|
6
|
-
memberjojo/mojo_transaction.py,sha256=rtyRfRPexFlnC9LFdAQe0yy56AaXeunyF6tSobG-Ipo,8203
|
|
7
|
-
memberjojo-1.1.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
|
|
8
|
-
memberjojo-1.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
9
|
-
memberjojo-1.1.dist-info/METADATA,sha256=2FrAn6BI1yO3pYJYdAWCdNGJGudsmpAK8yJX20XMC8c,3177
|
|
10
|
-
memberjojo-1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|