memberjojo 2.2__tar.gz → 3.0__tar.gz
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-2.2 → memberjojo-3.0}/PKG-INFO +18 -7
- {memberjojo-2.2 → memberjojo-3.0}/README.md +15 -5
- {memberjojo-2.2 → memberjojo-3.0}/pyproject.toml +1 -1
- {memberjojo-2.2 → memberjojo-3.0}/src/memberjojo/__init__.py +2 -0
- {memberjojo-2.2 → memberjojo-3.0}/src/memberjojo/_version.py +3 -3
- memberjojo-3.0/src/memberjojo/download.py +116 -0
- memberjojo-3.0/src/memberjojo/mojo_common.py +372 -0
- {memberjojo-2.2 → memberjojo-3.0}/src/memberjojo/mojo_loader.py +127 -59
- {memberjojo-2.2 → memberjojo-3.0}/src/memberjojo/mojo_member.py +122 -33
- {memberjojo-2.2 → memberjojo-3.0}/src/memberjojo/mojo_transaction.py +9 -8
- memberjojo-3.0/src/memberjojo/sql_query.py +12 -0
- memberjojo-3.0/src/memberjojo/url.py +82 -0
- memberjojo-3.0/tests/__init__.py +0 -0
- memberjojo-3.0/tests/test_merge_import.py +76 -0
- {memberjojo-2.2 → memberjojo-3.0}/tests/test_mojo_import_diff.py +1 -10
- {memberjojo-2.2 → memberjojo-3.0}/tests/test_mojo_members.py +79 -1
- {memberjojo-2.2 → memberjojo-3.0}/tests/test_mojo_members_iter.py +0 -1
- {memberjojo-2.2 → memberjojo-3.0}/tests/test_mojo_transactions.py +57 -12
- memberjojo-3.0/tests/utils.py +13 -0
- memberjojo-2.2/src/memberjojo/mojo_common.py +0 -257
- {memberjojo-2.2 → memberjojo-3.0}/LICENSE +0 -0
- {memberjojo-2.2 → memberjojo-3.0}/tests/test_version.py +0 -0
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memberjojo
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.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
|
|
11
10
|
Requires-Dist: coverage ; extra == "dev"
|
|
12
11
|
Requires-Dist: flit ; extra == "dev"
|
|
13
12
|
Requires-Dist: pytest ; extra == "dev"
|
|
@@ -17,28 +16,34 @@ Requires-Dist: sphinx>=7.0 ; extra == "docs"
|
|
|
17
16
|
Requires-Dist: sphinx-autodoc-typehints ; extra == "docs"
|
|
18
17
|
Requires-Dist: black ; extra == "lint"
|
|
19
18
|
Requires-Dist: pylint ; extra == "lint"
|
|
19
|
+
Requires-Dist: sqlcipher3 ; extra == "sqlcipher"
|
|
20
20
|
Project-URL: Home, https://github.com/a16bitsysop/memberjojo
|
|
21
21
|
Provides-Extra: dev
|
|
22
22
|
Provides-Extra: docs
|
|
23
23
|
Provides-Extra: lint
|
|
24
|
+
Provides-Extra: sqlcipher
|
|
24
25
|
|
|
25
26
|
# memberjojo
|
|
26
27
|
|
|
27
28
|
`memberjojo` is a Python library for using [Membermojo](http://membermojo.co.uk/)
|
|
28
29
|
data from CSV imports.\
|
|
29
30
|
It provides member database, and completed payments querying.\
|
|
30
|
-
This is done in a local SQLite database which is encrypted
|
|
31
|
-
anything on Membermojo.\
|
|
31
|
+
This is done in a local SQLite database which is optionally encrypted if sqlcipher3
|
|
32
|
+
is installed, and does not alter anything on Membermojo.\
|
|
32
33
|
It provides tools to load, and query membership and transaction data efficiently
|
|
33
34
|
without having to use SQLite directly.\
|
|
34
35
|
When importing CSV files existing entries are dropped before import, so you can
|
|
35
|
-
just import the latest download and the local database is updated
|
|
36
|
+
just import the latest download and the local database is updated with a summary
|
|
37
|
+
diff printed out.
|
|
38
|
+
|
|
39
|
+
Using the download_csv function the csv can be downloaded directly into the db,
|
|
40
|
+
which can also be in memory if :memory: is used as the db path.
|
|
36
41
|
|
|
37
42
|
---
|
|
38
43
|
|
|
39
44
|
## Installation
|
|
40
45
|
|
|
41
|
-
Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
|
|
46
|
+
Installing via `pip` on macos with optional `sqlcipher` installed via homebrew:\
|
|
42
47
|
(The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH` and
|
|
43
48
|
`LIBRARY_PATH` are needed for the `libsqlcipher` files to be found)
|
|
44
49
|
|
|
@@ -46,13 +51,19 @@ Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
|
|
|
46
51
|
brew install sqlcipher
|
|
47
52
|
export C_INCLUDE_PATH="/opt/homebrew/opt/sqlcipher/include"
|
|
48
53
|
export LIBRARY_PATH="/opt/homebrew/opt/sqlcipher/lib"
|
|
49
|
-
pip install memberjojo
|
|
54
|
+
pip install memberjojo[sqlciper]
|
|
50
55
|
```
|
|
51
56
|
|
|
52
57
|
Installing via `pip` on ubuntu:
|
|
53
58
|
|
|
54
59
|
```bash
|
|
55
60
|
sudo apt-get --no-install-recommends --no-install-suggests install libsqlcipher-dev
|
|
61
|
+
pip install memberjojo[sqlcipher]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Installing via `pip` without sqlcipher:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
56
67
|
pip install memberjojo
|
|
57
68
|
```
|
|
58
69
|
|
|
@@ -3,18 +3,22 @@
|
|
|
3
3
|
`memberjojo` is a Python library for using [Membermojo](http://membermojo.co.uk/)
|
|
4
4
|
data from CSV imports.\
|
|
5
5
|
It provides member database, and completed payments querying.\
|
|
6
|
-
This is done in a local SQLite database which is encrypted
|
|
7
|
-
anything on Membermojo.\
|
|
6
|
+
This is done in a local SQLite database which is optionally encrypted if sqlcipher3
|
|
7
|
+
is installed, and does not alter anything on Membermojo.\
|
|
8
8
|
It provides tools to load, and query membership and transaction data efficiently
|
|
9
9
|
without having to use SQLite directly.\
|
|
10
10
|
When importing CSV files existing entries are dropped before import, so you can
|
|
11
|
-
just import the latest download and the local database is updated
|
|
11
|
+
just import the latest download and the local database is updated with a summary
|
|
12
|
+
diff printed out.
|
|
13
|
+
|
|
14
|
+
Using the download_csv function the csv can be downloaded directly into the db,
|
|
15
|
+
which can also be in memory if :memory: is used as the db path.
|
|
12
16
|
|
|
13
17
|
---
|
|
14
18
|
|
|
15
19
|
## Installation
|
|
16
20
|
|
|
17
|
-
Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
|
|
21
|
+
Installing via `pip` on macos with optional `sqlcipher` installed via homebrew:\
|
|
18
22
|
(The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH` and
|
|
19
23
|
`LIBRARY_PATH` are needed for the `libsqlcipher` files to be found)
|
|
20
24
|
|
|
@@ -22,13 +26,19 @@ Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
|
|
|
22
26
|
brew install sqlcipher
|
|
23
27
|
export C_INCLUDE_PATH="/opt/homebrew/opt/sqlcipher/include"
|
|
24
28
|
export LIBRARY_PATH="/opt/homebrew/opt/sqlcipher/lib"
|
|
25
|
-
pip install memberjojo
|
|
29
|
+
pip install memberjojo[sqlciper]
|
|
26
30
|
```
|
|
27
31
|
|
|
28
32
|
Installing via `pip` on ubuntu:
|
|
29
33
|
|
|
30
34
|
```bash
|
|
31
35
|
sudo apt-get --no-install-recommends --no-install-suggests install libsqlcipher-dev
|
|
36
|
+
pip install memberjojo[sqlcipher]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Installing via `pip` without sqlcipher:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
32
42
|
pip install memberjojo
|
|
33
43
|
```
|
|
34
44
|
|
|
@@ -9,13 +9,13 @@ readme = "README.md"
|
|
|
9
9
|
license = "MIT"
|
|
10
10
|
license-files = ["LICENSE"]
|
|
11
11
|
dynamic = ["version", "description"]
|
|
12
|
-
dependencies = ["sqlcipher3"]
|
|
13
12
|
requires-python = ">=3.8"
|
|
14
13
|
|
|
15
14
|
[project.urls]
|
|
16
15
|
Home = "https://github.com/a16bitsysop/memberjojo"
|
|
17
16
|
|
|
18
17
|
[project.optional-dependencies]
|
|
18
|
+
sqlcipher = ["sqlcipher3"]
|
|
19
19
|
dev = [
|
|
20
20
|
"coverage",
|
|
21
21
|
"flit",
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '
|
|
32
|
-
__version_tuple__ = version_tuple = (
|
|
31
|
+
__version__ = version = '3.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (3, 0)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'g594a20473'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A class for downloading from membermojo
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import re
|
|
7
|
+
from http.cookiejar import MozillaCookieJar
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .url import URL
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Download:
|
|
15
|
+
"""A class for managing Membermojo downloads"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, shortname: str, cookie_jar: MozillaCookieJar):
|
|
18
|
+
"""
|
|
19
|
+
Initialise the class
|
|
20
|
+
|
|
21
|
+
:param shortname: the membermojo shortname
|
|
22
|
+
:param cookie_jar: a MozillaCookieJar with the session cookie, or empty to get one
|
|
23
|
+
"""
|
|
24
|
+
self.url = URL(shortname)
|
|
25
|
+
self.cookie_jar = cookie_jar
|
|
26
|
+
self.session = requests.Session()
|
|
27
|
+
self.session.cookies = self.cookie_jar
|
|
28
|
+
|
|
29
|
+
def fill_login(self):
|
|
30
|
+
"""
|
|
31
|
+
Prompt for email and password to get login data
|
|
32
|
+
|
|
33
|
+
:return: a dict of the login data
|
|
34
|
+
"""
|
|
35
|
+
email = input("📧 Enter your Membermojo email: ").strip()
|
|
36
|
+
password = getpass.getpass("🔐 Enter your password: ").strip()
|
|
37
|
+
|
|
38
|
+
# Submit login form (this triggers verification email if needed)
|
|
39
|
+
return {"email": email, "password": password}
|
|
40
|
+
|
|
41
|
+
def mojo_login(self, login_data: dict, email_verify: bool = False):
|
|
42
|
+
"""
|
|
43
|
+
Login to membermojo, cookie jar should be saved afterwards with updated cookie
|
|
44
|
+
|
|
45
|
+
:param login_data: a dict containing email and password for requests
|
|
46
|
+
:param email_verify: if True membermojo email verification will be triggered
|
|
47
|
+
if login fails, or no cookie found to create a new session cookie
|
|
48
|
+
|
|
49
|
+
:raises ValueError: If authentication fails and email_verify is False
|
|
50
|
+
"""
|
|
51
|
+
if not self.session.cookies:
|
|
52
|
+
if email_verify:
|
|
53
|
+
print("🍪 No cookies saved, triggering email verification.")
|
|
54
|
+
self.trigger_email(login_data)
|
|
55
|
+
else:
|
|
56
|
+
raise ValueError("⚠️ No cookies found — email verification required.")
|
|
57
|
+
self.session.post(self.url.login, data=login_data)
|
|
58
|
+
|
|
59
|
+
# Attempt to access a protected page to verify login worked
|
|
60
|
+
print(f"Verifying login with: {self.url.test}")
|
|
61
|
+
verify_response = self.session.get(self.url.test)
|
|
62
|
+
if "<mm2-loginpage" in verify_response.text:
|
|
63
|
+
if email_verify:
|
|
64
|
+
print("📧 Authentication failed, triggering email verification")
|
|
65
|
+
self.trigger_email(login_data)
|
|
66
|
+
else:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"⚠️ Authentication Failed — email verification required."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def trigger_email(self, login_data: dict):
|
|
72
|
+
"""
|
|
73
|
+
Triggers a login verification email, prompts the user for the verification URL,
|
|
74
|
+
and then submits it to complete the login process.
|
|
75
|
+
|
|
76
|
+
:param login_data: A dictionary containing login credentials (e.g., email)
|
|
77
|
+
|
|
78
|
+
:raises: ValueError: If a CSRF token cannot be found or if the login form submission fails
|
|
79
|
+
"""
|
|
80
|
+
self.session.cookies.clear()
|
|
81
|
+
response = self.session.post(self.url.login, data=login_data)
|
|
82
|
+
|
|
83
|
+
if "check your email" in response.text.lower() or response.ok:
|
|
84
|
+
print("✅ Login submitted — check your inbox for a verification link.")
|
|
85
|
+
|
|
86
|
+
# Get CSRF token from homepage
|
|
87
|
+
homepage = self.session.get(self.url.base_url)
|
|
88
|
+
match = re.search(r'"csrf_token":"([^"]+)"', homepage.text)
|
|
89
|
+
if not match:
|
|
90
|
+
raise ValueError("❌ Could not find CSRF token.")
|
|
91
|
+
|
|
92
|
+
csrf_token = match.group(1)
|
|
93
|
+
print(f"✅ CSRF token: {csrf_token}")
|
|
94
|
+
|
|
95
|
+
# Ask user for the verification link
|
|
96
|
+
verification_url = input(
|
|
97
|
+
"🔗 Paste the verification URL from the email: "
|
|
98
|
+
).strip()
|
|
99
|
+
|
|
100
|
+
# Submit the verification request
|
|
101
|
+
verify_response = self.session.post(
|
|
102
|
+
verification_url, data={"csrf_token": csrf_token}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Output
|
|
106
|
+
if verify_response.ok:
|
|
107
|
+
print("✅ Verification successful. You're now logged in.")
|
|
108
|
+
else:
|
|
109
|
+
print("⚠️ Verification may have failed.")
|
|
110
|
+
verify_html = Path("verify.html")
|
|
111
|
+
with verify_html.open("w", encoding="UTF-8") as f:
|
|
112
|
+
f.write(verify_response.text)
|
|
113
|
+
|
|
114
|
+
else:
|
|
115
|
+
print(response.text)
|
|
116
|
+
raise ValueError("❌ Failed to submit login form.")
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MojoSkel base class
|
|
3
|
+
|
|
4
|
+
This module provides a common base class (`MojoSkel`) for other `memberjojo` modules
|
|
5
|
+
It includes helper methods for working with SQLite databases
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import make_dataclass
|
|
9
|
+
from decimal import Decimal, InvalidOperation
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from pprint import pprint
|
|
12
|
+
from typing import Any, Iterator, List, Type, Union
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from sqlcipher3 import dbapi2 as sqlite3
|
|
18
|
+
|
|
19
|
+
HAS_SQLCIPHER = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
import sqlite3 # stdlib
|
|
22
|
+
|
|
23
|
+
HAS_SQLCIPHER = False
|
|
24
|
+
|
|
25
|
+
from . import mojo_loader
|
|
26
|
+
from .sql_query import Like
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MojoSkel:
|
|
30
|
+
"""
|
|
31
|
+
Establishes a connection to a SQLite database and provides helper methods
|
|
32
|
+
for querying tables
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, db_path: str, db_key: str, table_name: str):
|
|
36
|
+
"""
|
|
37
|
+
Initialize the MojoSkel class
|
|
38
|
+
|
|
39
|
+
Connects to the SQLite database and sets the row factory for
|
|
40
|
+
dictionary-style access to columns.
|
|
41
|
+
|
|
42
|
+
:param db_path: Path to the SQLite database file
|
|
43
|
+
:param db_key: key to unlock the encrypted sqlite database,
|
|
44
|
+
unencrypted if sqlcipher3 not installed or unset
|
|
45
|
+
:param table_name: Name of the table to operate on, or create when importing
|
|
46
|
+
"""
|
|
47
|
+
self.db_path = db_path
|
|
48
|
+
self.table_name = table_name
|
|
49
|
+
self.db_key = db_key
|
|
50
|
+
self.debug = False
|
|
51
|
+
|
|
52
|
+
# Open connection
|
|
53
|
+
self.conn = sqlite3.connect(self.db_path) # pylint: disable=no-member
|
|
54
|
+
self.conn.row_factory = sqlite3.Row # pylint: disable=no-member
|
|
55
|
+
self.cursor = self.conn.cursor()
|
|
56
|
+
|
|
57
|
+
if HAS_SQLCIPHER and db_key:
|
|
58
|
+
# Apply SQLCipher key
|
|
59
|
+
self.cursor.execute(f"PRAGMA key='{db_key}'")
|
|
60
|
+
self.cursor.execute("PRAGMA cipher_compatibility = 4")
|
|
61
|
+
print(
|
|
62
|
+
"Cipher:", self.cursor.execute("PRAGMA cipher_version;").fetchone()[0]
|
|
63
|
+
)
|
|
64
|
+
print(f"Encrypted database {self.db_path} loaded securely.")
|
|
65
|
+
else:
|
|
66
|
+
print(f"Unencrypted database {self.db_path} loaded securely.")
|
|
67
|
+
|
|
68
|
+
# After table exists (or after import), build the dataclass
|
|
69
|
+
if self.table_exists():
|
|
70
|
+
self.row_class = self._build_dataclass_from_table()
|
|
71
|
+
else:
|
|
72
|
+
self.row_class = None
|
|
73
|
+
|
|
74
|
+
def __iter__(self) -> Iterator[Any]:
|
|
75
|
+
"""
|
|
76
|
+
Allow iterating over the class, by outputing all members
|
|
77
|
+
"""
|
|
78
|
+
if not self.row_class:
|
|
79
|
+
raise RuntimeError("Table not loaded yet — no dataclass available")
|
|
80
|
+
return self._iter_rows()
|
|
81
|
+
|
|
82
|
+
def _row_to_obj(self, row: sqlite3.Row) -> Type[Any]:
|
|
83
|
+
"""
|
|
84
|
+
Convert an sqlite3 row into a dataclass object
|
|
85
|
+
|
|
86
|
+
:param row: The sqlite3 row to convert
|
|
87
|
+
|
|
88
|
+
:return: A dataclass object of the row
|
|
89
|
+
"""
|
|
90
|
+
row_dict = dict(row)
|
|
91
|
+
|
|
92
|
+
# Convert REAL → Decimal (including numeric strings)
|
|
93
|
+
for k, v in row_dict.items():
|
|
94
|
+
if isinstance(v, float):
|
|
95
|
+
row_dict[k] = Decimal(str(v))
|
|
96
|
+
elif isinstance(v, str):
|
|
97
|
+
try:
|
|
98
|
+
row_dict[k] = Decimal(v)
|
|
99
|
+
except InvalidOperation:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
return self.row_class(**row_dict)
|
|
103
|
+
|
|
104
|
+
def _iter_rows(self) -> Iterator[Any]:
|
|
105
|
+
"""
|
|
106
|
+
Iterate over table rows and yield dynamically-created dataclass objects
|
|
107
|
+
Converts REAL columns to Decimal automatically
|
|
108
|
+
|
|
109
|
+
:return: An interator of dataclass objects for rows
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
sql = f'SELECT * FROM "{self.table_name}"'
|
|
113
|
+
|
|
114
|
+
cur = self.conn.cursor()
|
|
115
|
+
cur.execute(sql)
|
|
116
|
+
|
|
117
|
+
for row in cur.fetchall():
|
|
118
|
+
yield self._row_to_obj(row)
|
|
119
|
+
|
|
120
|
+
def _build_dataclass_from_table(self) -> Type[Any]:
|
|
121
|
+
"""
|
|
122
|
+
Dynamically create a dataclass from the table schema
|
|
123
|
+
INTEGER → int
|
|
124
|
+
REAL → Decimal
|
|
125
|
+
TEXT → str
|
|
126
|
+
|
|
127
|
+
:return: A dataclass built from the table columns and types
|
|
128
|
+
|
|
129
|
+
:raises ValueError: If no table
|
|
130
|
+
"""
|
|
131
|
+
self.cursor.execute(f'PRAGMA table_info("{self.table_name}")')
|
|
132
|
+
cols = self.cursor.fetchall()
|
|
133
|
+
|
|
134
|
+
if not cols:
|
|
135
|
+
raise ValueError(f"Table '{self.table_name}' does not exist")
|
|
136
|
+
|
|
137
|
+
fields = []
|
|
138
|
+
for _cid, name, col_type, _notnull, _dflt, _pk in cols:
|
|
139
|
+
t = col_type.upper()
|
|
140
|
+
|
|
141
|
+
if t.startswith("INT"):
|
|
142
|
+
py_type = int
|
|
143
|
+
elif t.startswith("REAL") or t.startswith("NUM") or t.startswith("DEC"):
|
|
144
|
+
py_type = Decimal
|
|
145
|
+
else:
|
|
146
|
+
py_type = str
|
|
147
|
+
|
|
148
|
+
fields.append((name, py_type))
|
|
149
|
+
|
|
150
|
+
return make_dataclass(f"{self.table_name}_Row", fields)
|
|
151
|
+
|
|
152
|
+
def rename_old_table(self, existing: bool) -> str:
|
|
153
|
+
"""
|
|
154
|
+
If there was an exising table rename for comparison
|
|
155
|
+
|
|
156
|
+
:param existing: bool for table exists
|
|
157
|
+
|
|
158
|
+
:return: the old table name
|
|
159
|
+
"""
|
|
160
|
+
old_table = f"{self.table_name}_old"
|
|
161
|
+
self.conn.execute(f"DROP TABLE IF EXISTS {old_table}")
|
|
162
|
+
# Preserve existing table
|
|
163
|
+
if existing:
|
|
164
|
+
self.conn.execute(f"ALTER TABLE {self.table_name} RENAME TO {old_table}")
|
|
165
|
+
return old_table
|
|
166
|
+
|
|
167
|
+
def print_diff(self, old_table: str):
|
|
168
|
+
"""
|
|
169
|
+
Print out diff between old and new db
|
|
170
|
+
|
|
171
|
+
:param old_table: The name the existing table was renamed to
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
# Diff old vs new (SQLCipher → sqlite3 → dataclasses)
|
|
175
|
+
diff_rows = mojo_loader.diff_cipher_tables(
|
|
176
|
+
self.conn,
|
|
177
|
+
new_table=self.table_name,
|
|
178
|
+
old_table=old_table,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if diff_rows:
|
|
182
|
+
for diff in diff_rows:
|
|
183
|
+
# diff is a DiffRow dataclass
|
|
184
|
+
print(diff.diff_type, diff.preview)
|
|
185
|
+
|
|
186
|
+
finally:
|
|
187
|
+
# Cleanup old table (always)
|
|
188
|
+
self.conn.execute(f"DROP TABLE {old_table}")
|
|
189
|
+
|
|
190
|
+
def download_csv(self, session: requests.Session, url: str, merge: bool = False):
|
|
191
|
+
"""
|
|
192
|
+
Download the CSV from url and import into the sqlite database
|
|
193
|
+
If a previous table exists, generate a diff
|
|
194
|
+
|
|
195
|
+
:param session: Requests session to use for download
|
|
196
|
+
:param url: url of the csv to download
|
|
197
|
+
:param merge: (optional) If True, merge into existing table. Defaults to False.
|
|
198
|
+
"""
|
|
199
|
+
had_existing = False
|
|
200
|
+
old_table = ""
|
|
201
|
+
|
|
202
|
+
if not merge:
|
|
203
|
+
had_existing = self.table_exists()
|
|
204
|
+
old_table = self.rename_old_table(had_existing)
|
|
205
|
+
|
|
206
|
+
# Download CSV as new table
|
|
207
|
+
mojo_loader.download_csv_helper(
|
|
208
|
+
self.conn, self.table_name, url, session, merge=merge
|
|
209
|
+
)
|
|
210
|
+
self.row_class = self._build_dataclass_from_table()
|
|
211
|
+
|
|
212
|
+
if merge:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
if not had_existing:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
self.print_diff(old_table)
|
|
219
|
+
|
|
220
|
+
def import_csv(self, csv_path: Path, merge: bool = False):
|
|
221
|
+
"""
|
|
222
|
+
Import the passed CSV into the encrypted sqlite database
|
|
223
|
+
|
|
224
|
+
:param csv_path: Path like path of csv file
|
|
225
|
+
:param merge: (optional) If True, merge into existing table. Defaults to False.
|
|
226
|
+
Form importing current, and expired members as headings are the same.
|
|
227
|
+
"""
|
|
228
|
+
had_existing = False
|
|
229
|
+
old_table = ""
|
|
230
|
+
|
|
231
|
+
if not merge:
|
|
232
|
+
had_existing = self.table_exists()
|
|
233
|
+
old_table = self.rename_old_table(had_existing)
|
|
234
|
+
|
|
235
|
+
# Import CSV as new table
|
|
236
|
+
mojo_loader.import_csv_helper(self.conn, self.table_name, csv_path, merge=merge)
|
|
237
|
+
self.row_class = self._build_dataclass_from_table()
|
|
238
|
+
|
|
239
|
+
if merge:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
if not had_existing:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
self.print_diff(old_table)
|
|
246
|
+
|
|
247
|
+
def show_table(self, limit: int = 2):
|
|
248
|
+
"""
|
|
249
|
+
Print the first few rows of the table as dictionaries
|
|
250
|
+
|
|
251
|
+
:param limit: (optional) Number of rows to display. Defaults to 2
|
|
252
|
+
"""
|
|
253
|
+
if self.table_exists():
|
|
254
|
+
self.cursor.execute(f'SELECT * FROM "{self.table_name}" LIMIT ?', (limit,))
|
|
255
|
+
rows = self.cursor.fetchall()
|
|
256
|
+
|
|
257
|
+
else:
|
|
258
|
+
print("(No data)")
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
for row in rows:
|
|
262
|
+
print(dict(row))
|
|
263
|
+
|
|
264
|
+
def count(self) -> int:
|
|
265
|
+
"""
|
|
266
|
+
:return: count of the number of rows in the table, or 0 if no table
|
|
267
|
+
"""
|
|
268
|
+
if self.table_exists():
|
|
269
|
+
self.cursor.execute(f'SELECT COUNT(*) FROM "{self.table_name}"')
|
|
270
|
+
result = self.cursor.fetchone()
|
|
271
|
+
return result[0] if result else 0
|
|
272
|
+
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
def get_row(
|
|
276
|
+
self, entry_name: str, entry_value: str, only_one: bool = True
|
|
277
|
+
) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
|
|
278
|
+
"""
|
|
279
|
+
Retrieve a single or multiple rows matching column = value (case-insensitive)
|
|
280
|
+
|
|
281
|
+
:param entry_name: Column name to filter by
|
|
282
|
+
:param entry_value: Value to match
|
|
283
|
+
:param only_one: If True (default), return the first matching row
|
|
284
|
+
If False, return a list of all matching rows
|
|
285
|
+
|
|
286
|
+
:return:
|
|
287
|
+
- If only_one=True → a single sqlite3.Row or None
|
|
288
|
+
- If only_one=False → list of sqlite3.Row (may be empty)
|
|
289
|
+
"""
|
|
290
|
+
match_dict = {f"{entry_name}": entry_value}
|
|
291
|
+
|
|
292
|
+
return self.get_row_multi(match_dict, only_one)
|
|
293
|
+
|
|
294
|
+
def get_row_multi(
|
|
295
|
+
self, match_dict: dict, only_one: bool = True
|
|
296
|
+
) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
|
|
297
|
+
"""
|
|
298
|
+
Retrieve one or many rows matching multiple column=value pairs
|
|
299
|
+
|
|
300
|
+
:param match_dict: Dictionary of column names and values to match
|
|
301
|
+
:param only_one: If True (default), return the first matching row
|
|
302
|
+
If False, return a list of all matching rows
|
|
303
|
+
|
|
304
|
+
:return:
|
|
305
|
+
- If only_one=True → a single sqlite3.Row or None
|
|
306
|
+
- If only_one=False → list of sqlite3.Row (may be empty)
|
|
307
|
+
"""
|
|
308
|
+
conditions = []
|
|
309
|
+
values = []
|
|
310
|
+
|
|
311
|
+
for col, val in match_dict.items():
|
|
312
|
+
if val is None or val == "":
|
|
313
|
+
conditions.append(f'"{col}" IS NULL')
|
|
314
|
+
elif isinstance(val, Like):
|
|
315
|
+
conditions.append(f'LOWER("{col}") LIKE LOWER(?)')
|
|
316
|
+
values.append(val.pattern)
|
|
317
|
+
elif isinstance(val, (tuple, list)) and len(val) == 2:
|
|
318
|
+
lower, upper = val
|
|
319
|
+
if lower is not None and upper is not None:
|
|
320
|
+
conditions.append(f'"{col}" BETWEEN ? AND ?')
|
|
321
|
+
values.extend([lower, upper])
|
|
322
|
+
elif lower is not None:
|
|
323
|
+
conditions.append(f'"{col}" >= ?')
|
|
324
|
+
values.append(lower)
|
|
325
|
+
elif upper is not None:
|
|
326
|
+
conditions.append(f'"{col}" <= ?')
|
|
327
|
+
values.append(upper)
|
|
328
|
+
else:
|
|
329
|
+
# Both are None, effectively no condition on this column
|
|
330
|
+
pass
|
|
331
|
+
else:
|
|
332
|
+
conditions.append(f'"{col}" = ?')
|
|
333
|
+
values.append(
|
|
334
|
+
float(val.quantize(Decimal("0.01")))
|
|
335
|
+
if isinstance(val, Decimal)
|
|
336
|
+
else val
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Base query string
|
|
340
|
+
base_query = (
|
|
341
|
+
f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)}'
|
|
342
|
+
)
|
|
343
|
+
if self.debug:
|
|
344
|
+
print("Sql:")
|
|
345
|
+
pprint(base_query)
|
|
346
|
+
|
|
347
|
+
if only_one:
|
|
348
|
+
query = base_query + " LIMIT 1"
|
|
349
|
+
self.cursor.execute(query, values)
|
|
350
|
+
if row := self.cursor.fetchone():
|
|
351
|
+
return self._row_to_obj(row)
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
self.cursor.execute(base_query, values)
|
|
355
|
+
return [self._row_to_obj(row) for row in self.cursor.fetchall()]
|
|
356
|
+
|
|
357
|
+
def run_count_query(self, sql: str, params: tuple):
|
|
358
|
+
"""
|
|
359
|
+
Generate whole sql query for running on db table for counting and run
|
|
360
|
+
|
|
361
|
+
:param sql: the sqlite query for matching rows
|
|
362
|
+
:param params: the paramaters to use for the query
|
|
363
|
+
"""
|
|
364
|
+
sql_query = f"SELECT count (*) FROM {self.table_name} {sql}"
|
|
365
|
+
cursor = self.cursor.execute(sql_query, params)
|
|
366
|
+
return cursor.fetchone()[0]
|
|
367
|
+
|
|
368
|
+
def table_exists(self) -> bool:
|
|
369
|
+
"""
|
|
370
|
+
Return True or False if a table exists
|
|
371
|
+
"""
|
|
372
|
+
return mojo_loader.table_exists(self.cursor, self.table_name)
|