diff --git a/db/migration.py b/db/migration.py index 4bf9c20..b36da1d 100644 --- a/db/migration.py +++ b/db/migration.py @@ -25,6 +25,7 @@ __STEPS = [ CREATE TABLE notification ( id INTEGER PRIMARY KEY NOT NULL CHECK (typeof(id) = 'integer'), id_transaction INTEGER NOT NULL CHECK (typeof(id_transaction) = 'integer'), + user TEXT NOT NULL CHECK (typeof(user) = 'text'), 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'), diff --git a/db/notification.py b/db/notification.py index fbdf93d..6f3a5b5 100644 --- a/db/notification.py +++ b/db/notification.py @@ -2,14 +2,16 @@ from dataclasses import dataclass from datetime import datetime import logging import sqlite3 +from typing import Iterable, Tuple -from db import conn +from db.transaction import Transaction @dataclass(frozen=True) class Notification: id: int id_transaction: int + user: str pushover_receipt: str acknowledged: bool expired: bool @@ -28,6 +30,7 @@ class NotificationManager: """ INSERT INTO notification ( id_transaction, + user, pushover_receipt, acknowledged, expired, @@ -39,11 +42,13 @@ class NotificationManager: ?, ?, ?, + ?, ? ) RETURNING id; """, ( notification.id_transaction, + notification.user, notification.pushover_receipt, notification.acknowledged, notification.expired, @@ -57,9 +62,130 @@ class NotificationManager: return Notification( id=result[0], id_transaction=notification.id_transaction, + user=notification.user, pushover_receipt=notification.pushover_receipt, acknowledged=notification.acknowledged, expired=notification.expired, ts_update=now, ts_insert=now, ) + + def expire_notification(self, notification_id: int) -> Notification: + row = self.__db_conn.execute( + """ + UPDATE notification + SET expired = TRUE, ts_update = strftime('%s') + WHERE id = ? + RETURNING + id_transaction, + user, + pushover_receipt, + acknowledged, + ts_update, + ts_insert + """, + (notification_id,), + ).fetchone() + + self.__db_conn.commit() + + return Notification( + id=notification_id, + id_transaction=row[0], + user=row[1], + pushover_receipt=row[2], + acknowledged=row[3], + expired=True, + ts_insert=datetime.fromtimestamp(row[4]), + ts_update=datetime.fromtimestamp(row[5]), + ) + + def acknowledge_notification(self, notification_id: int) -> Notification: + row = self.__db_conn.execute( + """ + UPDATE notification + SET acknowledged = TRUE, ts_update = strftime('%s') + WHERE id = ? + RETURNING + id_transaction, + user, + pushover_receipt, + acknowledged, + ts_update, + ts_insert + """, + (notification_id,), + ).fetchone() + + self.__db_conn.commit() + + return Notification( + id=notification_id, + id_transaction=row[0], + user=row[1], + pushover_receipt=row[2], + acknowledged=row[3], + expired=True, + ts_insert=datetime.fromtimestamp(row[4]), + ts_update=datetime.fromtimestamp(row[5]), + ) + + def list_unacknowledged_transactions_with_notifications(self) -> Iterable[Tuple[Transaction, Notification]]: + result = self.__db_conn.execute( + """ + SELECT + t.id, + t.email_message_id, + t.amount, + t.card_ending_in, + t.merchant, + t.date, + t.time, + t.acknowledged, + t.ts_update, + t.ts_insert, + n.id, + n.id_transaction, + n.user, + n.pushover_receipt, + n.acknowledged, + n.expired, + n.ts_update, + n.ts_insert + FROM "transaction" AS t + JOIN notification AS n + ON t.id = n.id_transaction + WHERE + t.acknowledged = FALSE AND + n.expired = FALSE AND + n.acknowledged = FALSE + GROUP BY t.id + HAVING n.id = MAX(n.id) + """, + ) + + for row in result: + yield ( + Transaction( + id=row[0], + email_message_id=row[1], + amount=row[2], + card_ending_in=row[3], + merchant=row[4], + date=row[5], + time=row[6], + acknowledged=row[7], + ts_update=datetime.fromtimestamp(row[8]), + ts_insert=datetime.fromtimestamp(row[9]), + ), + Notification( + id=row[10], + id_transaction=row[11], + user=row[12], + pushover_receipt=row[13], + acknowledged=row[14], + expired=row[15], + ts_update=datetime.fromtimestamp(row[16]), + ts_insert=datetime.fromtimestamp(row[17]), + ), + ) \ No newline at end of file diff --git a/db/transaction.py b/db/transaction.py index adf35f9..6951cbe 100644 --- a/db/transaction.py +++ b/db/transaction.py @@ -1,7 +1,7 @@ from datetime import datetime import logging import sqlite3 -from typing import Iterable, Optional +from typing import Optional from dataclasses import dataclass @@ -56,10 +56,45 @@ class TransactionManager: date=result[5], time=result[6], acknowledged=result[7], - ts_update=result[8], - ts_insert=result[9], + ts_update=datetime.fromtimestamp(result[8]), + ts_insert=datetime.fromtimestamp(result[9]), ) + def acknowledge_transaction(self, transaction_id: int) -> Transaction: + row = self.__db_conn.execute( + """ + UPDATE "transaction" + SET acknowledged = TRUE, ts_update = strftime('%s') + WHERE id = ? + RETURNING + id, + email_message_id, + amount, + card_ending_in, + merchant, + date, + time, + acknowledged, + ts_update, + ts_insert + """, + (transaction_id,), + ).fetchone() + + return Transaction( + id=row[0], + email_message_id=row[1], + amount=row[2], + card_ending_in=row[3], + merchant=row[4], + date=row[5], + time=row[6], + acknowledged=row[7], + ts_update=datetime.fromtimestamp(row[8]), + ts_insert=datetime.fromtimestamp(row[9]), + ) + + def insert_transaction(self, transaction: Transaction) -> Transaction: now = datetime.now() result = self.__db_conn.execute( diff --git a/main.py b/main.py index e0f421b..765204d 100755 --- a/main.py +++ b/main.py @@ -15,6 +15,9 @@ from db import migrate_db, create_conn from db.notification import Notification, NotificationManager from db.transaction import Transaction, TransactionManager + +__MAX_PUSHOVER_EXPIRATION = 10800 # 3 hours, max allowed by Pushover's API + __logger = None @@ -131,7 +134,7 @@ def grouper(data: Iterable, group_size: int) -> Iterable[List]: yield group -def send_pushover(token: str, user: str, transaction: Transaction) -> dict[str, Any]: +def send_pushover(notification_manager: NotificationManager, token: str, user: str, transaction: Transaction) -> dict[str, Any]: conn = http.client.HTTPSConnection("api.pushover.net:443") try: conn.request( @@ -142,8 +145,8 @@ def send_pushover(token: str, user: str, transaction: Transaction) -> dict[str, "token": token, "user": user, "priority": "2", - "retry": "3600", # 3 hours, max allowed by Pushover's API - "expire": "10800", # 3 hours, max allowed by Pushover's API + "retry": "3600", + "expire": f"{__MAX_PUSHOVER_EXPIRATION}", "message": f'{transaction.amount} at "{transaction.merchant}" with card {transaction.card_ending_in} on {transaction.date} at {transaction.time}', } ), @@ -154,7 +157,42 @@ def send_pushover(token: str, user: str, transaction: Transaction) -> dict[str, if response.getcode() != 200: raise Exception(f"failed to send notifcation via Pushover: {str(body, 'utf-8')}") - return json.loads(body) + response_payload = json.loads(body) + + notification = notification_manager.insert_notification(Notification( + id=0, + ts_insert=datetime.now(), + ts_update=datetime.now(), + acknowledged=False, + expired=False, + id_transaction=transaction.id, + pushover_receipt=response_payload["receipt"], + user=pushover_user, + )) + + log.debug(f"recording notification id {notification.id}") + + return response_payload + finally: + conn.close() + +def get_pushover_receipt_status(receipt: str, token: str) -> dict[str, Any]: + conn = http.client.HTTPSConnection("api.pushover.net:443") + try: + conn.request( + "GET", + f"/1/receipts/{receipt}.json", + urllib.parse.urlencode( + { + "token": token, + } + ), + {"Content-type": "application/x-www-form-urlencoded"}, + ) + + response = conn.getresponse() + + return json.loads(response.read()) finally: conn.close() @@ -252,17 +290,25 @@ if __name__ == "__main__": 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) + response = send_pushover(notification_manager, pushover_token, pushover_user, transaction) log.info(f"recorded {count} transactions") + + log.info("checking transactions without an acknowledgement") + for transaction, notification in notification_manager.list_unacknowledged_transactions_with_notifications(): + response = get_pushover_receipt_status(notification.pushover_receipt, pushover_token) + + # if status is falsey, it has been >= 1 week + if not response["status"] or datetime.now() >= notification.ts_update + timedelta(days=1): + log.info(f"notification id {notification.id} for transaction id {transaction.id} is expired, retrying") + send_pushover(notification_manager, pushover_token, pushover_user, transaction) + notification_manager.expire_notification(notification.id) + continue + + if response["acknowledged"]: + log.info(f"notification id {notification.id} for transaction id {transaction.id} is acknowledged") + notification_manager.acknowledge_notification(notification.id) + transaction_manager.acknowledge_transaction(transaction.id) + continue + + log.debug(f"notification id {notification.id} is not expired nor acknowledged, continuing") \ No newline at end of file