From be1ff5929dc3f1f9ebe3e29ce7266ec9b3d3dad5 Mon Sep 17 00:00:00 2001 From: brucewzj99 Date: Fri, 3 May 2024 21:23:37 +0800 Subject: [PATCH 1/9] added workflow, move database service, added faq --- .github/workflows/main.yml | 24 +++ .gitignore | 4 +- FAQ.md | 27 ++++ LICENSE | 2 +- bot/database_service/firestore_service.py | 78 +++++++-- bot/firestore_config.py | 12 -- bot/firestore_service.py | 45 ------ bot/telegram_bot.py | 184 +++++++++++++++------- test_polling.py => dev_polling.py | 2 +- test_webhook.py => dev_webhook.py | 0 doc-image/faq-tracker.png | Bin 0 -> 47723 bytes release_notes.md | 12 +- requirements.txt | 6 + test/test_database.py | 27 ++++ 14 files changed, 287 insertions(+), 136 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 FAQ.md delete mode 100644 bot/firestore_config.py delete mode 100644 bot/firestore_service.py rename test_polling.py => dev_polling.py (96%) rename test_webhook.py => dev_webhook.py (100%) create mode 100644 doc-image/faq-tracker.png create mode 100644 test/test_database.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..4e5b9a8 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,24 @@ +name: Python application test + +on: + push: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.9.13 + uses: actions/setup-python@v2 + with: + python-version: 3.9.13 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run tests + run: | + python -m pytest \ No newline at end of file diff --git a/.gitignore b/.gitignore index 04ee175..ef170da 100644 --- a/.gitignore +++ b/.gitignore @@ -161,4 +161,6 @@ cython_debug/ .vercel # My stuff -test_func.py \ No newline at end of file +test_func.py +users_backup.json +one_off_func.py \ No newline at end of file diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000..24075a2 --- /dev/null +++ b/FAQ.md @@ -0,0 +1,27 @@ +## FAQ on TeleFinance Tracker Bot + +### 1. Sometimes the bot doesn't reply when I enter data. What should I do? +The bot may occasionally encounter issues due to hosting limitations. If it doesn't respond, try waiting, or use `/cancel` and re-enter the data. Most of the time, `/cancel` would solve majority of your issues! + +### 2. My entry ends up in the wrong category. How do I fix this? +When keying new entry, do ensure that you have received the "Transaction logged." message before adding a new one. For your current incorrect entries, you can manually adjust them in the Google Sheet. + +### 3. How do I add past transactions or entries for previous months? +For entries from past months, you can manually add them directly to the Google Sheet. For transactions earlier in the current month, use the `/backlog` command. + +### 4. How do I delete or edit a past entry? +To delete or edit past entries, manually adjust them in the Google Sheet. Remember not to shift the remaining entries up, as this could disrupt new entries. + +### 5. Can I add or edit quick settings for `/quickothers` or `/quicktransport`? +Yes, you can customize these settings by modifying the tracker tab in the Google Sheet. There is a limit set in the bot to prevent excessively long lists. + +### 6. How do I view all the commands available? +You can view all available commands by opening the menu in the Telegram chat or by typing `/help`. + +### 7. I edited the Google Sheet, but the bot doesn't seem to recognize the changes. What should I do? +Do ensure that `TRACKER` under the Tracker tab is correctly updated. The first row refers to the row number the first entry is on, the Transport Row and Other Row should refers to the last entry of the respective category. This means there's possiblity that the Other or Transport Row is -1 of first row. + +![tracker fixed](https://github.com/brucewzj99/tele-tracker-v2/doc-image/faq-tracker.png) + + +### If you have additional questions or need further assistance, feel free to [open a new issue](https://github.com/brucewzj99/tele-tracker-v2/issues) on GitHub. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 5be567f..4fa6916 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Bruce Wang +Copyright (c) 2024 Bruce Wang Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/bot/database_service/firestore_service.py b/bot/database_service/firestore_service.py index 7705049..beacf17 100644 --- a/bot/database_service/firestore_service.py +++ b/bot/database_service/firestore_service.py @@ -1,47 +1,93 @@ from bot.database_service.auth import get_db_client -from datetime import datetime +from datetime import datetime, timedelta +import pytz + class FirestoreService: """ This class is responsible for managing the Firestore database. """ - def __init__(self): + def __init__(self, collection_name="users"): self.db = get_db_client() + self.collection_name = collection_name # New user setup def new_user_setup(self, telegram_id, sheet_id, telegram_username): - user_ref = self.db.collection("users").document(str(telegram_id)) - user_ref.set({ - "sheet_id": sheet_id, - "datetime_created": datetime.now(), - "username": telegram_username - }) + user_ref = self.db.collection(self.collection_name).document(str(telegram_id)) + timestamp = datetime.now(pytz.timezone("Asia/Singapore")) + user_ref.set( + { + "sheet_id": sheet_id, + "datetime_created": timestamp, + "username": telegram_username, + "usage_count": 0, + "last_accessed": timestamp, + "hourly_accessed": timestamp, + "overusage_count": 0, + } + ) # Check if user exists def check_if_user_exists(self, telegram_id): - user_ref = self.db.collection("users").document(str(telegram_id)) + user_ref = self.db.collection(self.collection_name).document(str(telegram_id)) user_doc = user_ref.get() return user_doc.exists # Get user sheet id - def get_user_sheet_id(self, telegram_id): - user_ref = self.db.collection("users").document(str(telegram_id)) + def get_user_sheet_id(self, telegram_id, telegram_username): + user_ref = self.db.collection(self.collection_name).document(str(telegram_id)) user_doc = user_ref.get() + if user_doc.exists: - return user_doc.get("sheet_id") - else: - return None + try: + # Update username if it is different + if user_doc.get("username") != telegram_username: + user_ref.update({"username": telegram_username}) + + # Get the current time + now = datetime.now(pytz.timezone("Asia/Singapore")) + # Retrieve the hourly accessed time + hourly_accessed = user_doc.get("hourly_accessed") + + if not hourly_accessed: + hourly_accessed = now + + usage_count = user_doc.get("usage_count") + overusage_count = user_doc.get("overusage_count") + if (now - hourly_accessed) < timedelta(hours=1): + if usage_count < 30: + usage_count += 1 + else: + overusage_count += 1 # Increment overusage count if limit reached within the hour + else: + # Reset if a new hour has started + usage_count = 1 + hourly_accessed = now + + # Update the last accessed time, usage count and overusage count + user_ref.update( + { + "last_accessed": now, + "hourly_accessed": hourly_accessed, + "usage_count": usage_count, + "overusage_count": overusage_count, + } + ) + return user_doc.get("sheet_id") + except Exception as e: + raise e + return None # Get all user IDs def get_all_user_id(self): - users_ref = self.db.collection("users") + users_ref = self.db.collection(self.collection_name) user_ids = [int(user.id) for user in users_ref.stream()] return user_ids # Get all sheet IDs def get_all_sheet_id(self): - users_ref = self.db.collection("users") + users_ref = self.db.collection(self.collection_name) sheet_ids = [] for user in users_ref.stream(): sheet_ids.append(user.get("sheet_id")) diff --git a/bot/firestore_config.py b/bot/firestore_config.py deleted file mode 100644 index 4ad0fe5..0000000 --- a/bot/firestore_config.py +++ /dev/null @@ -1,12 +0,0 @@ -from firebase_admin import firestore -import firebase_admin -from firebase_admin import credentials -import os -import json - -firebase_json = json.loads(os.environ["FIREBASE_JSON"]) - -cred = credentials.Certificate(firebase_json) -firebase_admin.initialize_app(cred) - -db = firestore.client() diff --git a/bot/firestore_service.py b/bot/firestore_service.py deleted file mode 100644 index d2177ab..0000000 --- a/bot/firestore_service.py +++ /dev/null @@ -1,45 +0,0 @@ -from datetime import datetime -from bot.firestore_config import db - - -# New user setup -def new_user_setup(telegram_id, sheet_id, telegram_username): - user_ref = db.collection("users").document(str(telegram_id)) - user_ref.set({ - "sheet_id": sheet_id, - "datetime_created": datetime.now(), - "username": telegram_username - }) - - -# Check if user exists -def check_if_user_exists(telegram_id): - user_ref = db.collection("users").document(str(telegram_id)) - user_doc = user_ref.get() - return user_doc.exists - - -# Get user sheet id -def get_user_sheet_id(telegram_id): - user_ref = db.collection("users").document(str(telegram_id)) - user_doc = user_ref.get() - if user_doc.exists: - return user_doc.get("sheet_id") - else: - return None - - -# Get all user IDs -def get_all_user_id(): - users_ref = db.collection("users") - user_ids = [int(user.id) for user in users_ref.stream()] - return user_ids - - -# Get all sheet IDs -def get_all_sheet_id(): - users_ref = db.collection("users") - sheet_ids = [] - for user in users_ref.stream(): - sheet_ids.append(user.get("sheet_id")) - return sheet_ids diff --git a/bot/telegram_bot.py b/bot/telegram_bot.py index f4118bd..1349c60 100644 --- a/bot/telegram_bot.py +++ b/bot/telegram_bot.py @@ -16,12 +16,14 @@ from bot.common import EntryType from bot.common import ConversationState as CS import bot.google_sheet_service as gs -import bot.firestore_service as db +from bot.database_service import firestore_service import bot.utils as utils +db = firestore_service.FirestoreService() timezone = pytz.timezone("Asia/Singapore") MASTER_TELE_ID = os.environ.get("MASTER_TELE_ID") + def get_category_text(sheet_id, entry_type): msg = "" markup_list = [] @@ -42,10 +44,13 @@ def get_payment_text(sheet_id): def start(update, context): context.user_data.clear() telegram_id = update.effective_user.id + telegram_username = update.effective_user.username try: user_exists = db.check_if_user_exists(telegram_id) if user_exists: - context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id) + context.user_data["sheet_id"] = db.get_user_sheet_id( + telegram_id, telegram_username + ) link = f"https://docs.google.com/spreadsheets/d/{context.user_data['sheet_id']}/edit" update.message.reply_text( f"Seems like you have already linked a Google sheet with us, do you want to link a different Google sheet with us?\n\n{link}", @@ -56,8 +61,9 @@ def start(update, context): update.message.reply_text(SETUP_TEXT, parse_mode=ParseMode.HTML) return CS.SET_UP except Exception as e: - - update.message.reply_text(ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e))) + update.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -128,7 +134,8 @@ def reset_up(update, context) -> int: def config(update, context): context.user_data.clear() telegram_id = update.effective_user.id - context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id) + telegram_username = update.effective_user.username + context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id, telegram_username) list = [ "Change Google Sheet", "Configure Quick Transport", @@ -179,7 +186,9 @@ def config_handler(update, context) -> int: ) return CS.CONFIG_SETUP except Exception as e: - update.callback_query.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + update.callback_query.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -198,8 +207,10 @@ def config_setup(update, context) -> int: ) return CS.CONFIG_CATEGORY except Exception as e: - - update.callback_query.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END update.callback_query.edit_message_text(END_TEXT, reply_markup=None) return ConversationHandler.END @@ -233,16 +244,18 @@ def config_category(update, context) -> int: ) return CS.CONFIG_SUBCATEGORY except Exception as e: - - update.callback_query.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END def config_subcategory(update, context) -> int: reply = update.callback_query.data - context.user_data[ - "config-category" - ] = f'{context.user_data["config-category"]} - {reply}' + context.user_data["config-category"] = ( + f'{context.user_data["config-category"]} - {reply}' + ) try: sheet_id = context.user_data["sheet_id"] payment_list = gs.get_main_dropdown_value(sheet_id, "Payment") @@ -256,8 +269,10 @@ def config_subcategory(update, context) -> int: ) return CS.CONFIG_PAYMENT except Exception as e: - - update.callback_query.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -276,16 +291,18 @@ def config_payment(update, context) -> int: ) return CS.CONFIG_SUBPAYMENT except Exception as e: - - update.callback_query.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END def config_subpayment(update, context) -> int: reply = update.callback_query.data - context.user_data[ - "config-payment" - ] = f'{context.user_data["config-payment"]} - {reply}' + context.user_data["config-payment"] = ( + f'{context.user_data["config-payment"]} - {reply}' + ) try: update.callback_query.answer() update.callback_query.edit_message_text( @@ -302,15 +319,18 @@ def config_subpayment(update, context) -> int: ) return ConversationHandler.END except Exception as e: - - update.callback_query.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END def add_entry(update, context): context.user_data.clear() telegram_id = update.effective_user.id - context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id) + telegram_username = update.effective_user.username + context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id, telegram_username) update.message.reply_text( ENTRY_TYPE_TEXT, reply_markup=utils.create_inline_markup( @@ -362,8 +382,10 @@ def remarks(update: Update, context) -> int: ) return CS.CATEGORY except Exception as e: - - update.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -407,8 +429,10 @@ def category(update, context) -> int: ) return CS.PAYMENT except Exception as e: - - update.callback_query.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -434,8 +458,10 @@ def subcategory(update, context) -> int: ) return CS.PAYMENT except Exception as e: - - update.callback_query.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -466,8 +492,10 @@ def payment(update, context) -> int: update.callback_query.message.reply_text("Transaction logged.") return ConversationHandler.END except Exception as e: - - update.callback_query.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -494,8 +522,10 @@ def subpayment(update, context) -> int: return ConversationHandler.END except Exception as e: - - update.callback_query.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -600,15 +630,20 @@ def cancel(update, context): def add_transport(update, context): context.user_data.clear() telegram_id = update.effective_user.id + telegram_username = update.effective_user.username try: - context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id) + context.user_data["sheet_id"] = db.get_user_sheet_id( + telegram_id, telegram_username + ) context.user_data["entry_type"] = EntryType.TRANSPORT setting_list = gs.get_quick_add_settings( context.user_data["sheet_id"], EntryType.TRANSPORT ) except Exception as e: - - update.callback_query.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END if not setting_list or not setting_list[0]: update.message.reply_text(QUICK_SETUP_TRANSPORT) @@ -637,15 +672,20 @@ def add_transport(update, context): def add_others(update, context): context.user_data.clear() telegram_id = update.effective_user.id + telegram_username = update.effective_user.username try: - context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id) + context.user_data["sheet_id"] = db.get_user_sheet_id( + telegram_id, telegram_username + ) context.user_data["entry_type"] = EntryType.OTHERS setting_list = gs.get_quick_add_settings( context.user_data["sheet_id"], EntryType.OTHERS ) except Exception as e: - - update.callback_query.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END if not setting_list or not setting_list[0]: update.message.reply_text(QUICK_SETUP_OTHER) @@ -693,10 +733,12 @@ def quick_add(update, context) -> int: update.message.reply_text("Transaction logged.") return ConversationHandler.END except Exception as e: - update.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + update.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END except Exception as e: - + update.message.reply_text("Please follow the format and try again.") return CS.QUICK_ADD @@ -708,26 +750,36 @@ def help(update, context): def get_day_transaction(update, context): context.user_data.clear() telegram_id = update.effective_user.id + telegram_username = update.effective_user.username try: - context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id) + context.user_data["sheet_id"] = db.get_user_sheet_id( + telegram_id, telegram_username + ) update.message.reply_text(GET_TRANSACTION_TEXT) return CS.HANDLE_GET_TRANSACTION except Exception as e: - - update.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END def get_overall(update, context): context.user_data.clear() telegram_id = update.effective_user.id + telegram_username = update.effective_user.username try: - context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id) + context.user_data["sheet_id"] = db.get_user_sheet_id( + telegram_id, telegram_username + ) update.message.reply_text(GET_OVERALL_TEXT) return CS.HANDLE_GET_OVERALL except Exception as e: - - update.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -769,8 +821,10 @@ def handle_get_transaction(update, context): update.message.reply_text(GET_TRANSACTION_TEXT) return CS.HANDLE_GET_TRANSACTION except Exception as e: - - update.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -806,20 +860,27 @@ def handle_get_overall(update, context): update.message.reply_text(GET_OVERALL_TEXT) return CS.HANDLE_GET_OVERALL except Exception as e: - - update.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END def add_income(update, context): context.user_data.clear() telegram_id = update.effective_user.id + telegram_username = update.effective_user.username try: - context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id) + context.user_data["sheet_id"] = db.get_user_sheet_id( + telegram_id, telegram_username + ) update.message.reply_text(ADD_INCOME_TEXT) except Exception as e: - - update.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END return CS.INCOME @@ -847,8 +908,10 @@ def income(update, context) -> int: ) return CS.WORK_PLACE except Exception as e: - - update.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -881,8 +944,10 @@ def cpf(update, context) -> int: update.callback_query.message.reply_text(INCOME_LIMIT_TEXT) return ConversationHandler.END except Exception as e: - - update.callback_query.message.reply_text(ERROR_TEXT + "\nError:\n" +utils.sanitize_error_message(str(e))) + + update.callback_query.message.reply_text( + ERROR_TEXT + "\nError:\n" + utils.sanitize_error_message(str(e)) + ) return ConversationHandler.END @@ -907,7 +972,8 @@ def add_backlog_entry(update, context) -> int: return CS.ADD_BACKLOG_ENTRY telegram_id = update.effective_user.id - context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id) + telegram_username = update.effective_user.username + context.user_data["sheet_id"] = db.get_user_sheet_id(telegram_id, telegram_username) update.message.reply_text( ENTRY_TYPE_TEXT, reply_markup=utils.create_inline_markup( diff --git a/test_polling.py b/dev_polling.py similarity index 96% rename from test_polling.py rename to dev_polling.py index cbacf5b..8bfcbb0 100644 --- a/test_polling.py +++ b/dev_polling.py @@ -9,7 +9,7 @@ filename=log_file, filemode="a", format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - level=logging.INFO, + level=logging.DEBUG, ) logger = logging.getLogger(__name__) diff --git a/test_webhook.py b/dev_webhook.py similarity index 100% rename from test_webhook.py rename to dev_webhook.py diff --git a/doc-image/faq-tracker.png b/doc-image/faq-tracker.png new file mode 100644 index 0000000000000000000000000000000000000000..2a4682b7502ee6703fff64bfa8dc894f3731a2ff GIT binary patch literal 47723 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 z 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 literal 0 HcmV?d00001 diff --git a/release_notes.md b/release_notes.md index 9769c93..2dec048 100644 --- a/release_notes.md +++ b/release_notes.md @@ -1,4 +1,14 @@ # Release Notes +## Version 2.3.4 - Date 3 May 2024 +### Enhancement 🔥 +- Added FAQ section + +### For Developer 🧑‍💻 +- Add accessed time and usage count to database for tracking purposes +- Rename `test_` prefix to `dev_` for developer testing +- Remove old firestore codes, use new database module +- Added test for database module + ## Version 2.3.3 - Date 1 May 2024 ### Minor Fix 🛠️ - Add datetime and username for logging purposes @@ -45,7 +55,7 @@ - Fix empty subcategory and subpayment bug ### New Features 🆕 -- Added a new shortcode to retrieve transaction for today `/today` +- Added a new shortcode to retrieve transaction for today ### For Developer 🧑‍💻 - Mini refactor of code, added utils folder to store helper functions diff --git a/requirements.txt b/requirements.txt index 29465d9..41b211a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ charset-normalizer==2.0.12 click==8.1.3 colorama==0.4.6 cryptography==40.0.2 +exceptiongroup==1.2.1 firebase-admin==6.1.0 Flask==2.2.2 google-api-core==2.11.0 @@ -25,10 +26,13 @@ grpcio-status==1.54.2 httplib2==0.22.0 idna==3.4 importlib-metadata==6.6.0 +iniconfig==2.0.0 itsdangerous==2.1.2 Jinja2==3.1.2 MarkupSafe==2.1.2 msgpack==1.0.5 +packaging==24.0 +pluggy==1.5.0 proto-plus==1.22.2 protobuf==4.23.2 pyasn1==0.5.0 @@ -37,12 +41,14 @@ pycparser==2.21 PyJWT==2.7.0 pyngrok==6.0.0 pyparsing==3.0.9 +pytest==8.2.0 python-telegram-bot==13.7 pytz==2023.3 PyYAML==6.0 requests==2.26.0 rsa==4.9 six==1.16.0 +tomli==2.0.1 tornado==6.3.2 typing_extensions==4.6.2 tzdata==2023.3 diff --git a/test/test_database.py b/test/test_database.py new file mode 100644 index 0000000..1945dd1 --- /dev/null +++ b/test/test_database.py @@ -0,0 +1,27 @@ +from bot.database_service import firestore_service + +from unittest import TestCase + + +class TestFirestoreService(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.db = firestore_service.FirestoreService(collection_name="test_users") + cls.telegram_id = "123456" + cls.sheet_id = "sheet123" + cls.telegram_username = "test_user" + + def test_check_if_user_exists(self): + # Act + user_exists = self.db.check_if_user_exists(self.telegram_id) + + # Assert + self.assertTrue(user_exists) + + def test_get_user_sheet_id(self): + # Act + sheet_id = self.db.get_user_sheet_id(self.telegram_id, self.telegram_username) + + # Assert + self.assertEqual(sheet_id, self.sheet_id) From be4018ce94179bc99e324b60135dffa2b2090dfe Mon Sep 17 00:00:00 2001 From: brucewzj99 Date: Fri, 3 May 2024 21:33:07 +0800 Subject: [PATCH 2/9] Update main.yml --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4e5b9a8..93651e5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,7 @@ name: Python application test on: push: - branches: [ main ] + branches: [ '*' ] jobs: build: From f38951aeaee150fc3a8b001cbbc28c2339e131c2 Mon Sep 17 00:00:00 2001 From: brucewzj99 Date: Fri, 3 May 2024 22:03:44 +0800 Subject: [PATCH 3/9] update workflow --- .github/workflows/main.yml | 4 +++- .gitignore | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 93651e5..e11d71b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,7 +8,9 @@ jobs: build: runs-on: ubuntu-latest - + env: + FIREBASE_JSON: ${{ secrets.FIREBASE_JSON }} + steps: - uses: actions/checkout@v2 - name: Set up Python 3.9.13 diff --git a/.gitignore b/.gitignore index ef170da..8a4f8c5 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,5 @@ cython_debug/ # My stuff test_func.py users_backup.json -one_off_func.py \ No newline at end of file +one_off_func.py +*.json \ No newline at end of file From dd519218abf25ebf5bfff441bdee28d527cdd22e Mon Sep 17 00:00:00 2001 From: brucewzj99 Date: Fri, 3 May 2024 22:05:07 +0800 Subject: [PATCH 4/9] Update test_database.py --- test/test_database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_database.py b/test/test_database.py index 1945dd1..60be571 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -7,7 +7,7 @@ class TestFirestoreService(TestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.db = firestore_service.FirestoreService(collection_name="test_users") + cls.db = firestore_service.FirestoreService() cls.telegram_id = "123456" cls.sheet_id = "sheet123" cls.telegram_username = "test_user" From 9db47f4a960d1e1ff4ec5a3bfcfad5a2caeca74c Mon Sep 17 00:00:00 2001 From: brucewzj99 Date: Fri, 3 May 2024 22:09:47 +0800 Subject: [PATCH 5/9] Update main.yml --- .github/workflows/main.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e11d71b..e71f5ae 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,9 +8,7 @@ jobs: build: runs-on: ubuntu-latest - env: - FIREBASE_JSON: ${{ secrets.FIREBASE_JSON }} - + environment: Test steps: - uses: actions/checkout@v2 - name: Set up Python 3.9.13 From b9ebc74c9e94888ca03c87d2fb0772613c1af0c7 Mon Sep 17 00:00:00 2001 From: brucewzj99 Date: Fri, 3 May 2024 22:16:59 +0800 Subject: [PATCH 6/9] Update main.yml --- .github/workflows/main.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e71f5ae..5dfd76e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,5 +20,7 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - name: Run tests + env: + FIREBASE_JSON: ${{ secrets.FIREBASE_JSON }} run: | - python -m pytest \ No newline at end of file + python -m pytest From e0665f49fe06956b6828f05fbce566234ac2f4e1 Mon Sep 17 00:00:00 2001 From: brucewzj99 Date: Fri, 3 May 2024 22:21:48 +0800 Subject: [PATCH 7/9] added faq in readme --- README.md | 4 +++- requirements.txt | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2d7fbbc..d785c67 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ A python telegram bot to help track daily expenses onto google sheet, hosted on ## Release Notes You can find the release notes over [here](https://github.com/brucewzj99/tele-tracker-v2/blob/master/release_notes.md). +## FAQ +You can find the FAQ over [here](https://github.com/brucewzj99/tele-tracker-v2/blob/main/FAQ.md). + ## Table of Contents - [Getting Started (Users)](#getting-started-users) - [Getting Started (Developers)](#getting-started-developers) @@ -43,7 +46,6 @@ pip install -r requirements.txt ``` .env BOT_TOKEN=your_bot_token TEST_TOKEN=your_test_token -DATABASE_URL=firebase_url GOOGLE_API_EMAIL=google_api_email FIREBASE_JSON=service_account_key GOOGLE_JSON=service_account_key diff --git a/requirements.txt b/requirements.txt index 41b211a..31f9d98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,6 +42,7 @@ PyJWT==2.7.0 pyngrok==6.0.0 pyparsing==3.0.9 pytest==8.2.0 +python-dotenv==1.0.1 python-telegram-bot==13.7 pytz==2023.3 PyYAML==6.0 From 1706169204fcb4a12abbda0237e9dab129a2a482 Mon Sep 17 00:00:00 2001 From: brucewzj99 Date: Fri, 3 May 2024 22:25:11 +0800 Subject: [PATCH 8/9] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d785c67..58319d9 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,10 @@ pip install -r requirements.txt * Set up Google Sheet API, download service account key * Retrieve Google Sheet API email * Set up Firebase Firestore Database, download service account key -* Retrieve your firebase database url * Set up telegram bot via [BotFather](https://t.me/BotFather) * Insert all of them into .env as follows, you can use py dotenv or set it as env variable in your venv * You can use the same TOKEN for BOT_TOKEN & TEST_TOKEN but I recommend using two different bots for testing and production +* I recommend having two different firebase databases for testing and production ``` .env BOT_TOKEN=your_bot_token @@ -55,9 +55,9 @@ MASTER_TELE_ID=your_telegram_id ### Step 3 * Proceed to project directory and run: ``` python -python3.9 test_polling.py +python3.9 dev_polling.py OR -python3.9 test_webhook.py +python3.9 dev_webhook.py ``` ## Usage From cd22d33f0ffba965511917248f4967aa368e9c2f Mon Sep 17 00:00:00 2001 From: brucewzj99 Date: Fri, 3 May 2024 22:28:25 +0800 Subject: [PATCH 9/9] Update dev_polling.py --- dev_polling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_polling.py b/dev_polling.py index 8bfcbb0..cbacf5b 100644 --- a/dev_polling.py +++ b/dev_polling.py @@ -9,7 +9,7 @@ filename=log_file, filemode="a", format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - level=logging.DEBUG, + level=logging.INFO, ) logger = logging.getLogger(__name__)