start the transition to sqlite3 for storage
This commit is contained in:
parent
fe02b11951
commit
bba899677f
8 changed files with 326 additions and 51 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -0,0 +1 @@
|
|||
*/__pycache__
|
4
db/__init__.py
Normal file
4
db/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from db.migration import migrate_db
|
||||
from db.conn import create_conn
|
||||
|
||||
__all__ = ["migrate_db", "create_conn"]
|
8
db/conn.py
Normal file
8
db/conn.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
import sqlite3
|
||||
|
||||
|
||||
def create_conn(db_path: str) -> sqlite3.Connection:
|
||||
db_conn = sqlite3.connect(db_path)
|
||||
db_conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
return db_conn
|
0
db/message.py
Normal file
0
db/message.py
Normal file
64
db/migration.py
Normal file
64
db/migration.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
import logging
|
||||
import sqlite3
|
||||
|
||||
__STEPS = [
|
||||
"""
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE "transaction" (
|
||||
id INTEGER PRIMARY KEY NOT NULL CHECK (typeof(id) = 'integer'),
|
||||
email_message_id TEXT NOT NULL CHECK (typeof(email_message_id) = 'text'),
|
||||
amount TEXT NOT NULL CHECK (typeof(amount) = 'text'),
|
||||
card_ending_in TEXT NOT NULL CHECK (typeof(card_ending_in) = 'text'),
|
||||
merchant TEXT NOT NULL CHECK (typeof(merchant) = 'text'),
|
||||
date TEXT NOT NULL CHECK (typeof(date) = 'text'),
|
||||
time TEXT NOT NULL CHECK (typeof(time) = 'text'),
|
||||
acknowledged BOOLEAN NOT NULL CHECK (typeof(acknowledged) = 'integer'),
|
||||
ts_update INTEGER NOT NULL CHECK (typeof(ts_update) = 'integer'),
|
||||
ts_insert INTEGER NOT NULL CHECK (typeof(ts_insert) = 'integer')
|
||||
);
|
||||
CREATE UNIQUE INDEX ix__transaction__email_message_id ON
|
||||
"transaction" (email_message_id);
|
||||
CREATE INDEX ix__transaction__acknowledged ON
|
||||
"transaction" (acknowledged);
|
||||
|
||||
CREATE TABLE notification (
|
||||
id INTEGER PRIMARY KEY NOT NULL CHECK (typeof(id) = 'integer'),
|
||||
id_transaction INTEGER NOT NULL CHECK (typeof(id_transaction) = 'integer'),
|
||||
pushover_receipt TEXT NOT NULL CHECK (typeof(pushover_receipt) = 'text'),
|
||||
acknowledged BOOLEAN NOT NULL CHECK (typeof(acknowledged) = 'integer'),
|
||||
expired BOOLEAN NOT NULL CHECK (typeof(expired) = 'integer'),
|
||||
ts_update INTEGER NOT NULL CHECK (typeof(ts_update) = 'integer'),
|
||||
ts_insert INTEGER NOT NULL CHECK (typeof(ts_insert) = 'integer'),
|
||||
FOREIGN KEY (id_transaction) REFERENCES "transaction" (id)
|
||||
);
|
||||
CREATE INDEX ix__notification__id_transaction ON
|
||||
notification (id_transaction);
|
||||
|
||||
CREATE TABLE schema_version (
|
||||
id INTEGER PRIMARY KEY NOT NULL CHECK (typeof(id) = 'integer'),
|
||||
ts_insert INTEGER NOT NULL CHECK (typeof(ts_insert) = 'integer')
|
||||
);
|
||||
|
||||
INSERT INTO schema_version (id, ts_insert) VALUES (1, strftime('%s'));
|
||||
|
||||
COMMIT;
|
||||
"""
|
||||
]
|
||||
|
||||
|
||||
def migrate_db(log: logging.Logger, db_conn: sqlite3.Connection) -> None:
|
||||
schema_verison = 0
|
||||
try:
|
||||
log.info("checking latest schema_version")
|
||||
result = db_conn.execute("SELECT MAX(id) FROM schema_version").fetchone()
|
||||
if result is not None:
|
||||
schema_verison = result[0]
|
||||
log.info(f"schema version is {schema_verison}")
|
||||
except Exception:
|
||||
log.info(f"missing schema_version table, assuming {schema_verison}")
|
||||
|
||||
for step_number, step in enumerate(__STEPS[schema_verison:], start=1):
|
||||
log.info(f"applying schema_version {step_number}")
|
||||
db_conn.executescript(step)
|
||||
log.info(f"successfully applied schema_version {step_number}")
|
65
db/notification.py
Normal file
65
db/notification.py
Normal file
|
@ -0,0 +1,65 @@
|
|||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from db import conn
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Notification:
|
||||
id: int
|
||||
id_transaction: int
|
||||
pushover_receipt: str
|
||||
acknowledged: bool
|
||||
expired: bool
|
||||
ts_update: datetime
|
||||
ts_insert: datetime
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
def __init__(self, log: logging.Logger, db_conn: sqlite3.Connection):
|
||||
self.__log = log
|
||||
self.__db_conn = db_conn
|
||||
|
||||
def insert_notification(self, notification: Notification) -> Notification:
|
||||
now = datetime.now()
|
||||
result = self.__db_conn.execute(
|
||||
"""
|
||||
INSERT INTO notification (
|
||||
id_transaction,
|
||||
pushover_receipt,
|
||||
acknowledged,
|
||||
expired,
|
||||
ts_update,
|
||||
ts_insert
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?
|
||||
) RETURNING id;
|
||||
""",
|
||||
(
|
||||
notification.id_transaction,
|
||||
notification.pushover_receipt,
|
||||
notification.acknowledged,
|
||||
notification.expired,
|
||||
now.strftime("%s"),
|
||||
now.strftime("%s"),
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
self.__db_conn.commit()
|
||||
|
||||
return Notification(
|
||||
id=result[0],
|
||||
id_transaction=notification.id_transaction,
|
||||
pushover_receipt=notification.pushover_receipt,
|
||||
acknowledged=notification.acknowledged,
|
||||
expired=notification.expired,
|
||||
ts_update=now,
|
||||
ts_insert=now,
|
||||
)
|
115
db/transaction.py
Normal file
115
db/transaction.py
Normal file
|
@ -0,0 +1,115 @@
|
|||
from datetime import datetime
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Iterable, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transaction:
|
||||
id: int
|
||||
email_message_id: str
|
||||
amount: str
|
||||
card_ending_in: str
|
||||
merchant: str
|
||||
date: str
|
||||
time: str
|
||||
acknowledged: bool
|
||||
ts_update: datetime
|
||||
ts_insert: datetime
|
||||
|
||||
|
||||
class TransactionManager:
|
||||
def __init__(self, log: logging.Logger, db_conn: sqlite3.Connection):
|
||||
self.__log = log
|
||||
self.__db_conn = db_conn
|
||||
|
||||
def get_by_email_message_id(self, email_message_id: str) -> Optional[Transaction]:
|
||||
result = self.__db_conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
email_message_id,
|
||||
amount,
|
||||
card_ending_in,
|
||||
merchant,
|
||||
date,
|
||||
time,
|
||||
acknowledged,
|
||||
ts_update,
|
||||
ts_insert
|
||||
FROM "transaction"
|
||||
WHERE email_message_id = ?
|
||||
""",
|
||||
(email_message_id,),
|
||||
).fetchone()
|
||||
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
return Transaction(
|
||||
id=result[0],
|
||||
email_message_id=result[1],
|
||||
amount=result[2],
|
||||
card_ending_in=result[3],
|
||||
merchant=result[4],
|
||||
date=result[5],
|
||||
time=result[6],
|
||||
acknowledged=result[7],
|
||||
ts_update=result[8],
|
||||
ts_insert=result[9],
|
||||
)
|
||||
|
||||
def insert_transaction(self, transaction: Transaction) -> Transaction:
|
||||
now = datetime.now()
|
||||
result = self.__db_conn.execute(
|
||||
"""
|
||||
INSERT INTO "transaction" (
|
||||
email_message_id,
|
||||
amount,
|
||||
card_ending_in,
|
||||
merchant,
|
||||
date,
|
||||
time,
|
||||
acknowledged,
|
||||
ts_update,
|
||||
ts_insert
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?
|
||||
) RETURNING id
|
||||
""",
|
||||
(
|
||||
transaction.email_message_id,
|
||||
transaction.amount,
|
||||
transaction.card_ending_in,
|
||||
transaction.merchant,
|
||||
transaction.date,
|
||||
transaction.time,
|
||||
transaction.acknowledged,
|
||||
int(now.strftime("%s")),
|
||||
int(now.strftime("%s")),
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
self.__db_conn.commit()
|
||||
|
||||
return Transaction(
|
||||
id=result[0],
|
||||
email_message_id=transaction.email_message_id,
|
||||
amount=transaction.amount,
|
||||
card_ending_in=transaction.card_ending_in,
|
||||
merchant=transaction.merchant,
|
||||
date=transaction.date,
|
||||
time=transaction.time,
|
||||
acknowledged=transaction.acknowledged,
|
||||
ts_update=now,
|
||||
ts_insert=now,
|
||||
)
|
120
main.py
120
main.py
|
@ -1,15 +1,20 @@
|
|||
#!/usr/bin/env python3
|
||||
import sqlite3
|
||||
import json
|
||||
import imaplib
|
||||
import logging
|
||||
import os
|
||||
import email
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any, Iterable, List, Set
|
||||
from typing import Any, Callable, Iterable, List, Set
|
||||
import http.client
|
||||
import urllib
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from db import migrate_db, create_conn
|
||||
from db.notification import Notification, NotificationManager
|
||||
from db.transaction import Transaction, TransactionManager
|
||||
|
||||
__logger = None
|
||||
|
||||
|
||||
|
@ -24,26 +29,9 @@ def get_logger() -> logging.Logger:
|
|||
return __logger
|
||||
|
||||
|
||||
class Transaction:
|
||||
def __init__(self, message_id="", amount="", card_ending_in="", merchant="", date="", time=""):
|
||||
self.message_id = message_id
|
||||
self.amount = amount
|
||||
self.card_ending_in = card_ending_in
|
||||
self.merchant = merchant
|
||||
self.date = date
|
||||
self.time = time
|
||||
|
||||
def all_set(self) -> bool:
|
||||
for val in self.__dict__.values():
|
||||
if not val:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class MyHTMLParser(HTMLParser):
|
||||
def __init__(self):
|
||||
self.output = Transaction()
|
||||
self.__output = {}
|
||||
self.__start_tds = 0
|
||||
self.__processing_card_ending_in = False
|
||||
self.__processing_merchant = False
|
||||
|
@ -51,6 +39,24 @@ class MyHTMLParser(HTMLParser):
|
|||
self.__processing_time = False
|
||||
super().__init__()
|
||||
|
||||
def output(self, email_message_id: str) -> Transaction:
|
||||
transaction = Transaction(
|
||||
id=0,
|
||||
acknowledged=False,
|
||||
amount=self.__output["amount"],
|
||||
card_ending_in=self.__output["card_ending_in"],
|
||||
date=self.__output["date"],
|
||||
email_message_id=email_message_id,
|
||||
merchant=self.__output["merchant"],
|
||||
time=self.__output["time"],
|
||||
ts_insert=datetime.now(),
|
||||
ts_update=datetime.now(),
|
||||
)
|
||||
|
||||
self.__output = {}
|
||||
|
||||
return transaction
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
self.__start_tds += 1
|
||||
|
||||
|
@ -59,7 +65,7 @@ class MyHTMLParser(HTMLParser):
|
|||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self.__start_tds > 0:
|
||||
if self.output.all_set():
|
||||
if len(self.__output) == 5:
|
||||
return
|
||||
|
||||
data = data.strip()
|
||||
|
@ -68,26 +74,26 @@ class MyHTMLParser(HTMLParser):
|
|||
|
||||
if self.__processing_card_ending_in:
|
||||
self.__processing_card_ending_in = False
|
||||
self.output.card_ending_in = data
|
||||
self.__output["card_ending_in"] = data
|
||||
return
|
||||
|
||||
if self.__processing_merchant:
|
||||
self.__processing_merchant = False
|
||||
self.output.merchant = data
|
||||
self.__output["merchant"] = data
|
||||
return
|
||||
|
||||
if self.__processing_date:
|
||||
self.__processing_date = False
|
||||
self.output.date = data
|
||||
self.__output["date"] = data
|
||||
return
|
||||
|
||||
if self.__processing_time:
|
||||
self.__processing_time = False
|
||||
self.output.time = data
|
||||
self.__output["time"] = data
|
||||
return
|
||||
|
||||
if data.startswith("Amount: $"):
|
||||
self.output.amount = data.removeprefix("Amount: ")
|
||||
self.__output["amount"] = data.removeprefix("Amount: ")
|
||||
return
|
||||
|
||||
if data == "Card Ending In":
|
||||
|
@ -159,7 +165,7 @@ def get_transactions(
|
|||
imap_user: str,
|
||||
imap_password: str,
|
||||
imap_mailbox: str,
|
||||
ignore_message_ids: Set[str],
|
||||
ignore_message_id_callback: Callable[[str], bool],
|
||||
) -> Iterable[Transaction]:
|
||||
log = get_logger()
|
||||
log.info(f"attempting to connect to {imap_host}:{imap_port} with SSL")
|
||||
|
@ -192,7 +198,7 @@ def get_transactions(
|
|||
msg = email.message_from_bytes(email_data[1])
|
||||
msg_id = msg.get("message-id")
|
||||
|
||||
if msg_id in ignore_message_ids:
|
||||
if ignore_message_id_callback(msg_id):
|
||||
log.debug(f"ignoring message id {msg_id}")
|
||||
continue
|
||||
|
||||
|
@ -208,43 +214,55 @@ def get_transactions(
|
|||
body = msg.get_payload(decode=True)
|
||||
|
||||
parser = MyHTMLParser()
|
||||
parser.output.message_id = msg_id
|
||||
parser.feed(str(body, "utf-8"))
|
||||
|
||||
yield parser.output
|
||||
yield parser.output(msg_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
log = get_logger()
|
||||
db_conn = create_conn(os.environ["DB_FILE_PATH"])
|
||||
migrate_db(log, db_conn)
|
||||
transaction_manager = TransactionManager(log, db_conn)
|
||||
notification_manager = NotificationManager(log, db_conn)
|
||||
|
||||
pushover_token = os.environ["PUSHOVER_TOKEN"]
|
||||
pushover_user = os.environ["PUSHOVER_USER"]
|
||||
|
||||
with open(os.environ["IMAP_PASSWORD_FILE"]) as password_file:
|
||||
imap_password = password_file.read()
|
||||
|
||||
message_id_ignore_set: Set[str] = set()
|
||||
with open(os.environ["MESSAGE_ID_LIST"], "a+") as message_id_file:
|
||||
message_id_file.seek(0, 0)
|
||||
for message_id in message_id_file:
|
||||
message_id_ignore_set.add(message_id.strip())
|
||||
|
||||
transactions = get_transactions(
|
||||
imap_host=os.environ["IMAP_HOST"],
|
||||
imap_port=int(os.environ["IMAP_PORT"]),
|
||||
imap_user=os.environ["IMAP_USER"],
|
||||
imap_password=imap_password,
|
||||
imap_mailbox=os.environ["IMAP_MAILBOX"],
|
||||
ignore_message_ids=message_id_ignore_set,
|
||||
transactions = get_transactions(
|
||||
imap_host=os.environ["IMAP_HOST"],
|
||||
imap_port=int(os.environ["IMAP_PORT"]),
|
||||
imap_user=os.environ["IMAP_USER"],
|
||||
imap_password=imap_password,
|
||||
imap_mailbox=os.environ["IMAP_MAILBOX"],
|
||||
ignore_message_id_callback=lambda email_message_id: transaction_manager.get_by_email_message_id(
|
||||
email_message_id
|
||||
)
|
||||
is not None,
|
||||
)
|
||||
|
||||
count = 0
|
||||
for transaction in transactions:
|
||||
count += 1
|
||||
count = 0
|
||||
for transaction in transactions:
|
||||
count += 1
|
||||
|
||||
log.info(f"got message id {transaction.message_id}: {json.dumps(transaction.__dict__)}")
|
||||
log.debug(f"sending pushover notification for message id {transaction.message_id}")
|
||||
send_pushover(pushover_token, pushover_user, transaction)
|
||||
log.debug(f"recording message id {transaction.message_id} to message id list")
|
||||
message_id_file.writelines([transaction.message_id, "\n"])
|
||||
log.info(f"got message id {transaction.email_message_id}: {transaction.__dict__}")
|
||||
log.debug(f"recording message id {transaction.email_message_id} to message id list")
|
||||
transaction = transaction_manager.insert_transaction(transaction)
|
||||
log.debug(f"sending pushover notification for message id {transaction.email_message_id}")
|
||||
notification_manager.insert_notification(
|
||||
Notification(
|
||||
id=0,
|
||||
id_transaction=transaction.id,
|
||||
pushover_receipt="foobar",
|
||||
acknowledged=False,
|
||||
expired=False,
|
||||
ts_insert=0,
|
||||
ts_update=0,
|
||||
)
|
||||
)
|
||||
# send_pushover(pushover_token, pushover_user, transaction)
|
||||
|
||||
log.info(f"recorded {count} transactions")
|
||||
log.info(f"recorded {count} transactions")
|
||||
|
|
Loading…
Reference in a new issue