From bb84680592fb389c0dd48c504c768cb2161c6c8b Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 13:45:31 -0400 Subject: [PATCH 01/43] Added voice recognition feature --- .idea/.gitignore | 8 ++++++++ .idea/DollarBot.iml | 15 +++++++++++++++ .idea/misc.xml | 4 ++++ .idea/modules.xml | 8 ++++++++ .idea/vcs.xml | 6 ++++++ code/code.py | 46 +++++++++++++++++++++++++++++++++++++++++++++ history.csv | 4 ++++ run.sh | 0 setup.sh | 0 user_limits.json | 6 ++++++ 10 files changed, 97 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/DollarBot.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 history.csv mode change 100644 => 100755 run.sh mode change 100644 => 100755 setup.sh create mode 100644 user_limits.json diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..13566b81b --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/DollarBot.iml b/.idea/DollarBot.iml new file mode 100644 index 000000000..6e7c09c28 --- /dev/null +++ b/.idea/DollarBot.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..3205808ec --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..0ed127fcd --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/code/code.py b/code/code.py index 13b84473c..7134bc8c1 100644 --- a/code/code.py +++ b/code/code.py @@ -47,8 +47,12 @@ import monthly import sendEmail import add_recurring +import os +import tempfile +import speech_recognition as sr from datetime import datetime from jproperties import Properties +from pydub import AudioSegment configs = Properties() @@ -179,6 +183,48 @@ def command_weekly(message): """ weekly.run(message, bot) +@bot.message_handler(content_types=['voice']) +def handle_voice(message): + # Get the voice file + file_info = bot.get_file(message.voice.file_id) + downloaded_file = bot.download_file(file_info.file_path) + + # Create a temporary OGG file + with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_ogg: + temp_ogg.write(downloaded_file) + temp_ogg_path = temp_ogg.name + + # Convert OGG to WAV + temp_wav_path = tempfile.NamedTemporaryFile(delete=False, suffix='.wav').name + audio = AudioSegment.from_ogg(temp_ogg_path) + audio.export(temp_wav_path, format='wav') + + # Use SpeechRecognition to convert voice to text + recognizer = sr.Recognizer() + with sr.AudioFile(temp_wav_path) as source: + audio_data = recognizer.record(source) + try: + text = recognizer.recognize_google(audio_data) + process_command(text, message) + except sr.UnknownValueError: + bot.reply_to(message, "Sorry, I could not understand the audio.") + except sr.RequestError as e: + bot.reply_to(message, "Could not request results from the speech recognition service.") + + # Cleanup: remove the temporary files + os.remove(temp_ogg_path) + os.remove(temp_wav_path) + +def process_command(text, message): + if "expense" in text: + command_add(message) + elif "history" in text: + command_history(message) # Call the existing history command + elif "budget" in text: + command_budget(message) # Call the existing budget command + else: + bot.send_message(message.chat.id, "I didn't recognize that command.") + # defines how the /monthly command has to be handled/processed @bot.message_handler(commands=["monthly"]) def command_monthly(message): diff --git a/history.csv b/history.csv new file mode 100644 index 000000000..57398bb9c --- /dev/null +++ b/history.csv @@ -0,0 +1,4 @@ +Date,Category,Amount +02-Oct-2024,Food,$ 12 +12-Oct-2024,Groceries,$ 10.0 +26-Oct-2024,Food,$ 95.0 diff --git a/run.sh b/run.sh old mode 100644 new mode 100755 diff --git a/setup.sh b/setup.sh old mode 100644 new mode 100755 diff --git a/user_limits.json b/user_limits.json new file mode 100644 index 000000000..6bd7dde21 --- /dev/null +++ b/user_limits.json @@ -0,0 +1,6 @@ +{ + "6365998385": { + "food": 90.0, + "grocery": 70.0 + } +} \ No newline at end of file From cb213117765b03623a54c7df1a674debdcb7111a Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 18:26:29 -0400 Subject: [PATCH 02/43] Added test cases for handle_voice --- test/__init__.py | 0 test/test_voice.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 test/__init__.py create mode 100644 test/test_voice.py diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/test_voice.py b/test/test_voice.py new file mode 100644 index 000000000..db0cb4fd6 --- /dev/null +++ b/test/test_voice.py @@ -0,0 +1,72 @@ +import unittest +from unittest.mock import patch, MagicMock +import tempfile +import os +from code import handle_voice, process_command + +class TestVoiceHandler(unittest.TestCase): + + @patch('bot.get_file') + @patch('bot.download_file') + @patch('tempfile.NamedTemporaryFile') + @patch('pydub.AudioSegment.from_ogg') + @patch('speech_recognition.Recognizer') + def test_handle_voice_success(self, MockRecognizer, MockAudioSegment, MockNamedTempFile, MockDownloadFile, MockGetFile): + # Mock file details and download + MockGetFile.return_value.file_path = 'fake_path.ogg' + MockDownloadFile.return_value = b'fake_ogg_data' + + # Mock tempfile behavior + temp_ogg = MagicMock() + temp_wav = MagicMock() + MockNamedTempFile.side_effect = [temp_ogg, temp_wav] + temp_ogg.name = 'temp.ogg' + temp_wav.name = 'temp.wav' + + # Mock audio conversion + MockAudioSegment.from_ogg.return_value.export = MagicMock() + + # Mock speech recognition + recognizer_instance = MockRecognizer.return_value + recognizer_instance.record.return_value = "fake_audio_data" + recognizer_instance.recognize_google.return_value = "this is a test expense" + + # Mock process_command function + with patch('process_command') as mock_process_command: + handle_voice(MagicMock()) # Simulate calling the voice handler + + # Assertions + MockGetFile.assert_called_once() + MockDownloadFile.assert_called_once() + MockAudioSegment.from_ogg.assert_called_once_with('temp.ogg') + recognizer_instance.recognize_google.assert_called_once() + mock_process_command.assert_called_once_with("this is a test expense", MagicMock()) + + # Cleanup mocks + os.remove('temp.ogg') + os.remove('temp.wav') + + @patch('bot.send_message') + def test_process_command(self, mock_send_message): + message = MagicMock() + + # Test different commands + with patch('command_add') as mock_command_add, \ + patch('command_history') as mock_command_history, \ + patch('command_budget') as mock_command_budget: + + process_command("add expense", message) + mock_command_add.assert_called_once_with(message) + + process_command("show history", message) + mock_command_history.assert_called_once_with(message) + + process_command("set budget", message) + mock_command_budget.assert_called_once_with(message) + + # Test unrecognized command + process_command("unknown command", message) + mock_send_message.assert_called_once_with(message.chat.id, "I didn't recognize that command.") + +if __name__ == '__main__': + unittest.main() From fc8ffe6e9396dd749a40414eaceb7cb3704a2c75 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 18:43:03 -0400 Subject: [PATCH 03/43] Added more commands that can handle voice --- code/code.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/code/code.py b/code/code.py index 7134bc8c1..475833c2f 100644 --- a/code/code.py +++ b/code/code.py @@ -205,6 +205,7 @@ def handle_voice(message): audio_data = recognizer.record(source) try: text = recognizer.recognize_google(audio_data) + bot.send_message(message.chat.id, f"I heard: \"{text}\"") process_command(text, message) except sr.UnknownValueError: bot.reply_to(message, "Sorry, I could not understand the audio.") @@ -222,6 +223,16 @@ def process_command(text, message): command_history(message) # Call the existing history command elif "budget" in text: command_budget(message) # Call the existing budget command + elif "menu" in text: + start_and_menu_command(message) + elif "help" in text: + show_help(message) + elif "weekly" in text: + command_weekly(message) + elif "monthly" in text: + command_monthly(message) + elif "predict" in text: + command_predict(message) else: bot.send_message(message.chat.id, "I didn't recognize that command.") From 7c791b2f4876dc7d4cbfcdd268797b2883e9dd31 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 18:57:33 -0400 Subject: [PATCH 04/43] Updated show help view --- code/code.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/code/code.py b/code/code.py index 475833c2f..b02a67181 100644 --- a/code/code.py +++ b/code/code.py @@ -53,6 +53,7 @@ from datetime import datetime from jproperties import Properties from pydub import AudioSegment +from telebot import types configs = Properties() @@ -107,16 +108,19 @@ def listener(user_requests): @bot.message_handler(commands=["help"]) def show_help(m): - - helper.read_json() chat_id = m.chat.id + message = ( + "*Here are the commands you can use:*\n" + "/add - Add a new expense πŸ’΅\n" + "/history - View your expense history πŸ“œ\n" + "/budget - Check your budget πŸ’³\n" + "/analytics - View graphical analytics πŸ“Š\n" + "For more info, type /faq or tap the button below πŸ‘‡" + ) + keyboard = types.InlineKeyboardMarkup() + keyboard.add(types.InlineKeyboardButton("FAQ", callback_data='faq')) + bot.send_message(chat_id, message, parse_mode='Markdown', reply_markup=keyboard) - message = "Here are the commands you can use: \n" - commands = helper.getCommands() - for c in commands: - message += "/" + c + ", " - message += "\nUse /menu for detailed instructions about these commands." - bot.send_message(chat_id, message) @bot.message_handler(commands=["faq"]) def faq(m): From d705421c4ade8dc52ac95c41f948d9bc49cc93b1 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 19:37:30 -0400 Subject: [PATCH 05/43] Improved menu display in start_and_menu_command using Inline keyboard buttons --- categories.json | 4 +++- code/code.py | 25 +++++++++++-------------- expenditure.png | Bin 0 -> 21709 bytes expense_history.png | Bin 0 -> 38603 bytes expense_report.pdf | Bin 0 -> 63578 bytes spend_wise.png | Bin 0 -> 18126 bytes 6 files changed, 14 insertions(+), 15 deletions(-) create mode 100644 expenditure.png create mode 100644 expense_history.png create mode 100644 expense_report.pdf create mode 100644 spend_wise.png diff --git a/categories.json b/categories.json index 664d4fd53..cecc7c930 100644 --- a/categories.json +++ b/categories.json @@ -1 +1,3 @@ -{ "categories" : "Food,Groceries,Utilities,Transport,Shopping,Miscellaneous" } \ No newline at end of file +{ + "categories": "Food,Groceries,Utilities,Transport,Shopping,Miscellaneous,miscelleneous" +} \ No newline at end of file diff --git a/code/code.py b/code/code.py index b02a67181..5b60fa500 100644 --- a/code/code.py +++ b/code/code.py @@ -145,26 +145,23 @@ def faq(m): # defines how the /start and /help commands have to be handled/processed @bot.message_handler(commands=["start", "menu"]) def start_and_menu_command(m): - """ - start_and_menu_command(m): Prints out the the main menu displaying the features that the - bot offers and the corresponding commands to be run from the Telegram UI to use these features. - Commands used to run this: commands=['start', 'menu'] - """ helper.read_json() chat_id = m.chat.id - text_intro = ( - ("Welcome to the Dollar Bot! \n" - "DollarBot can track all your expenses with simple and easy to use commands :) \n" - "Here is the complete menu. \n\n") + "*Welcome to the Dollar Bot!* \n" + "DollarBot can track all your expenses with simple and easy-to-use commands :) \n" + "Here is the complete menu:\n\n" ) commands = helper.getCommands() - for c in commands: - # generate help text out of the commands dictionary defined at the top - text_intro += "/" + c + ": " - text_intro += commands[c] + "\n\n" - bot.send_message(chat_id, text_intro) + keyboard = types.InlineKeyboardMarkup() + + for command, description in commands.items(): + button_text = f"/{command}" + keyboard.add(types.InlineKeyboardButton(text=button_text, callback_data=command)) + + text_intro += "_Click a command button to use it._" + bot.send_message(chat_id, text_intro, reply_markup=keyboard, parse_mode='Markdown') return True # defines how the /add command has to be handled/processed diff --git a/expenditure.png b/expenditure.png new file mode 100644 index 0000000000000000000000000000000000000000..1e7087fbcf93ff1c083eb65d3b4eb4427340148d GIT binary patch literal 21709 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 PqLkV`P30^li@^T}sPwdV literal 0 HcmV?d00001 diff --git a/expense_history.png b/expense_history.png new file mode 100644 index 0000000000000000000000000000000000000000..51f7bb4856bba9d259ecc0be9bb481fefc268a24 GIT binary patch literal 38603 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 WkF%b3kK&ReT@jPLlzc(w;eP|@)q;Eg literal 0 HcmV?d00001 diff --git a/expense_report.pdf b/expense_report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6b0260e816044ce8337ef89574e4f41722e77bbf GIT binary patch literal 63578 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 literal 0 HcmV?d00001 From ceda293ebc86bfac98f3bb8825ee09d287029566 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 19:39:56 -0400 Subject: [PATCH 06/43] Added command handling for different user interactions with buttons --- code/code.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/code/code.py b/code/code.py index 5b60fa500..43a345a57 100644 --- a/code/code.py +++ b/code/code.py @@ -164,6 +164,54 @@ def start_and_menu_command(m): bot.send_message(chat_id, text_intro, reply_markup=keyboard, parse_mode='Markdown') return True +@bot.callback_query_handler(func=lambda call: True) +def callback_query(call): + """ + Handles button clicks and executes the corresponding command actions. + """ + command = call.data # The command from the button clicked + + # Check which command was clicked and perform the corresponding action + if command == "help": + show_help(call.message) + elif command == "pdf": + command_pdf(call.message) + elif command == "add": + command_add(call.message) + elif command == "menu": + start_and_menu_command(call.message) + elif command == "add_recurring": + command_add_recurring(call.messsage) + elif command == "analytics": + command_analytics(call.message) + elif command == "predict": + command_predict(call.message) + elif command == "history": + command_history(call.message) + elif command == "delete": + command_delete(call.message) + elif command == "display": + command_display(call.message) + elif command == "edit": + command_edit(call.message) + elif command == "budget": + command_budget(call.message) + elif command == "updateCategory": + command_updateCategory(call.message) + elif command == "weekly": + command_weekly(call.message) + elif command == "monthly": + command_monthly(call.message) + elif command == "sendEmail": + command_sendEmail(call.message) + else: + response_text = "Command not recognized." + + # Acknowledge the button press + # Acknowledge the button press + bot.answer_callback_query(call.id) + bot.send_message(call.message.chat.id, response_text, parse_mode='Markdown') + # defines how the /add command has to be handled/processed @bot.message_handler(commands=["add"]) def command_add(message): From 9c8fec2b603f9b2e3e7b01c06b0245d4b7c60655 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 19:41:16 -0400 Subject: [PATCH 07/43] Fixed UnboundLocalError in callback_query_handler --- code/code.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/code/code.py b/code/code.py index 43a345a57..c6d01358f 100644 --- a/code/code.py +++ b/code/code.py @@ -170,6 +170,7 @@ def callback_query(call): Handles button clicks and executes the corresponding command actions. """ command = call.data # The command from the button clicked + response_text = "" # Initialize response_text to avoid UnboundLocalError # Check which command was clicked and perform the corresponding action if command == "help": @@ -210,7 +211,10 @@ def callback_query(call): # Acknowledge the button press # Acknowledge the button press bot.answer_callback_query(call.id) - bot.send_message(call.message.chat.id, response_text, parse_mode='Markdown') + + # Send the response message only if response_text is set + if response_text: + bot.send_message(call.message.chat.id, response_text, parse_mode='Markdown') # defines how the /add command has to be handled/processed @bot.message_handler(commands=["add"]) From 782b494df8914e866680d0d90a2ac66099ed2012 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 19:45:39 -0400 Subject: [PATCH 08/43] Added faq to command list in callback_query --- code/code.py | 2 ++ expenditure.png | Bin 21709 -> 23744 bytes 2 files changed, 2 insertions(+) diff --git a/code/code.py b/code/code.py index c6d01358f..cc6eb1767 100644 --- a/code/code.py +++ b/code/code.py @@ -205,6 +205,8 @@ def callback_query(call): command_monthly(call.message) elif command == "sendEmail": command_sendEmail(call.message) + elif command == "faq": + faq(call.message) else: response_text = "Command not recognized." diff --git a/expenditure.png b/expenditure.png index 1e7087fbcf93ff1c083eb65d3b4eb4427340148d..023be7787d0cbc7de1a8af4a5ebe1ed77fbafd89 100644 GIT binary patch literal 23744 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 PqLkV`P30^li@^T}sPwdV From e33563fa75690ea6194155c1a59f6609963d7da5 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 13:45:31 -0400 Subject: [PATCH 09/43] Added voice recognition feature --- .idea/.gitignore | 8 ++++++++ .idea/DollarBot.iml | 15 +++++++++++++++ .idea/misc.xml | 4 ++++ .idea/modules.xml | 8 ++++++++ .idea/vcs.xml | 6 ++++++ code/code.py | 46 +++++++++++++++++++++++++++++++++++++++++++++ history.csv | 4 ++++ run.sh | 0 setup.sh | 0 user_limits.json | 6 ++++++ 10 files changed, 97 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/DollarBot.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 history.csv mode change 100644 => 100755 run.sh mode change 100644 => 100755 setup.sh create mode 100644 user_limits.json diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..13566b81b --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/DollarBot.iml b/.idea/DollarBot.iml new file mode 100644 index 000000000..6e7c09c28 --- /dev/null +++ b/.idea/DollarBot.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..3205808ec --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..0ed127fcd --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/code/code.py b/code/code.py index 13b84473c..7134bc8c1 100644 --- a/code/code.py +++ b/code/code.py @@ -47,8 +47,12 @@ import monthly import sendEmail import add_recurring +import os +import tempfile +import speech_recognition as sr from datetime import datetime from jproperties import Properties +from pydub import AudioSegment configs = Properties() @@ -179,6 +183,48 @@ def command_weekly(message): """ weekly.run(message, bot) +@bot.message_handler(content_types=['voice']) +def handle_voice(message): + # Get the voice file + file_info = bot.get_file(message.voice.file_id) + downloaded_file = bot.download_file(file_info.file_path) + + # Create a temporary OGG file + with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_ogg: + temp_ogg.write(downloaded_file) + temp_ogg_path = temp_ogg.name + + # Convert OGG to WAV + temp_wav_path = tempfile.NamedTemporaryFile(delete=False, suffix='.wav').name + audio = AudioSegment.from_ogg(temp_ogg_path) + audio.export(temp_wav_path, format='wav') + + # Use SpeechRecognition to convert voice to text + recognizer = sr.Recognizer() + with sr.AudioFile(temp_wav_path) as source: + audio_data = recognizer.record(source) + try: + text = recognizer.recognize_google(audio_data) + process_command(text, message) + except sr.UnknownValueError: + bot.reply_to(message, "Sorry, I could not understand the audio.") + except sr.RequestError as e: + bot.reply_to(message, "Could not request results from the speech recognition service.") + + # Cleanup: remove the temporary files + os.remove(temp_ogg_path) + os.remove(temp_wav_path) + +def process_command(text, message): + if "expense" in text: + command_add(message) + elif "history" in text: + command_history(message) # Call the existing history command + elif "budget" in text: + command_budget(message) # Call the existing budget command + else: + bot.send_message(message.chat.id, "I didn't recognize that command.") + # defines how the /monthly command has to be handled/processed @bot.message_handler(commands=["monthly"]) def command_monthly(message): diff --git a/history.csv b/history.csv new file mode 100644 index 000000000..57398bb9c --- /dev/null +++ b/history.csv @@ -0,0 +1,4 @@ +Date,Category,Amount +02-Oct-2024,Food,$ 12 +12-Oct-2024,Groceries,$ 10.0 +26-Oct-2024,Food,$ 95.0 diff --git a/run.sh b/run.sh old mode 100644 new mode 100755 diff --git a/setup.sh b/setup.sh old mode 100644 new mode 100755 diff --git a/user_limits.json b/user_limits.json new file mode 100644 index 000000000..6bd7dde21 --- /dev/null +++ b/user_limits.json @@ -0,0 +1,6 @@ +{ + "6365998385": { + "food": 90.0, + "grocery": 70.0 + } +} \ No newline at end of file From 3481a95c9331887992987c8da6475998a9a448f1 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 18:26:29 -0400 Subject: [PATCH 10/43] Added test cases for handle_voice --- test/__init__.py | 0 test/test_voice.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 test/__init__.py create mode 100644 test/test_voice.py diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/test_voice.py b/test/test_voice.py new file mode 100644 index 000000000..db0cb4fd6 --- /dev/null +++ b/test/test_voice.py @@ -0,0 +1,72 @@ +import unittest +from unittest.mock import patch, MagicMock +import tempfile +import os +from code import handle_voice, process_command + +class TestVoiceHandler(unittest.TestCase): + + @patch('bot.get_file') + @patch('bot.download_file') + @patch('tempfile.NamedTemporaryFile') + @patch('pydub.AudioSegment.from_ogg') + @patch('speech_recognition.Recognizer') + def test_handle_voice_success(self, MockRecognizer, MockAudioSegment, MockNamedTempFile, MockDownloadFile, MockGetFile): + # Mock file details and download + MockGetFile.return_value.file_path = 'fake_path.ogg' + MockDownloadFile.return_value = b'fake_ogg_data' + + # Mock tempfile behavior + temp_ogg = MagicMock() + temp_wav = MagicMock() + MockNamedTempFile.side_effect = [temp_ogg, temp_wav] + temp_ogg.name = 'temp.ogg' + temp_wav.name = 'temp.wav' + + # Mock audio conversion + MockAudioSegment.from_ogg.return_value.export = MagicMock() + + # Mock speech recognition + recognizer_instance = MockRecognizer.return_value + recognizer_instance.record.return_value = "fake_audio_data" + recognizer_instance.recognize_google.return_value = "this is a test expense" + + # Mock process_command function + with patch('process_command') as mock_process_command: + handle_voice(MagicMock()) # Simulate calling the voice handler + + # Assertions + MockGetFile.assert_called_once() + MockDownloadFile.assert_called_once() + MockAudioSegment.from_ogg.assert_called_once_with('temp.ogg') + recognizer_instance.recognize_google.assert_called_once() + mock_process_command.assert_called_once_with("this is a test expense", MagicMock()) + + # Cleanup mocks + os.remove('temp.ogg') + os.remove('temp.wav') + + @patch('bot.send_message') + def test_process_command(self, mock_send_message): + message = MagicMock() + + # Test different commands + with patch('command_add') as mock_command_add, \ + patch('command_history') as mock_command_history, \ + patch('command_budget') as mock_command_budget: + + process_command("add expense", message) + mock_command_add.assert_called_once_with(message) + + process_command("show history", message) + mock_command_history.assert_called_once_with(message) + + process_command("set budget", message) + mock_command_budget.assert_called_once_with(message) + + # Test unrecognized command + process_command("unknown command", message) + mock_send_message.assert_called_once_with(message.chat.id, "I didn't recognize that command.") + +if __name__ == '__main__': + unittest.main() From eab72539308ed8cab6b235559d63b12338be87f0 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 18:43:03 -0400 Subject: [PATCH 11/43] Added more commands that can handle voice --- code/code.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/code/code.py b/code/code.py index 7134bc8c1..475833c2f 100644 --- a/code/code.py +++ b/code/code.py @@ -205,6 +205,7 @@ def handle_voice(message): audio_data = recognizer.record(source) try: text = recognizer.recognize_google(audio_data) + bot.send_message(message.chat.id, f"I heard: \"{text}\"") process_command(text, message) except sr.UnknownValueError: bot.reply_to(message, "Sorry, I could not understand the audio.") @@ -222,6 +223,16 @@ def process_command(text, message): command_history(message) # Call the existing history command elif "budget" in text: command_budget(message) # Call the existing budget command + elif "menu" in text: + start_and_menu_command(message) + elif "help" in text: + show_help(message) + elif "weekly" in text: + command_weekly(message) + elif "monthly" in text: + command_monthly(message) + elif "predict" in text: + command_predict(message) else: bot.send_message(message.chat.id, "I didn't recognize that command.") From 2f3062d0da024082acb24627eda33d2096f5355f Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 19:39:56 -0400 Subject: [PATCH 12/43] Added command handling for different user interactions with buttons --- code/code.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/code/code.py b/code/code.py index cc6eb1767..43a345a57 100644 --- a/code/code.py +++ b/code/code.py @@ -170,7 +170,6 @@ def callback_query(call): Handles button clicks and executes the corresponding command actions. """ command = call.data # The command from the button clicked - response_text = "" # Initialize response_text to avoid UnboundLocalError # Check which command was clicked and perform the corresponding action if command == "help": @@ -205,18 +204,13 @@ def callback_query(call): command_monthly(call.message) elif command == "sendEmail": command_sendEmail(call.message) - elif command == "faq": - faq(call.message) else: response_text = "Command not recognized." # Acknowledge the button press # Acknowledge the button press bot.answer_callback_query(call.id) - - # Send the response message only if response_text is set - if response_text: - bot.send_message(call.message.chat.id, response_text, parse_mode='Markdown') + bot.send_message(call.message.chat.id, response_text, parse_mode='Markdown') # defines how the /add command has to be handled/processed @bot.message_handler(commands=["add"]) From e7a4d9cc4a835f4a68912e6d8c3e0fafa6c7bb66 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sat, 26 Oct 2024 22:43:29 -0400 Subject: [PATCH 13/43] Fixed pylint error and other bugs --- code/code.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/code/code.py b/code/code.py index b849ad23b..31585120f 100644 --- a/code/code.py +++ b/code/code.py @@ -157,9 +157,9 @@ def start_and_menu_command(m): commands = helper.getCommands() keyboard = types.InlineKeyboardMarkup() - for command, description in commands.items(): + for command, _ in commands.items(): # Unpack the tuple to get the command name button_text = f"/{command}" - keyboard.add(types.InlineKeyboardButton(text=button_text, callback_data=command)) + keyboard.add(types.InlineKeyboardButton(text=button_text, callback_data=command)) # Use `command` as a string text_intro += "_Click a command button to use it._" bot.send_message(chat_id, text_intro, reply_markup=keyboard, parse_mode='Markdown') @@ -171,6 +171,7 @@ def callback_query(call): Handles button clicks and executes the corresponding command actions. """ command = call.data # The command from the button clicked + response_text = "" # Check which command was clicked and perform the corresponding action if command == "help": @@ -205,6 +206,8 @@ def callback_query(call): command_monthly(call.message) elif command == "sendEmail": command_sendEmail(call.message) + elif command == "faq": + faq(call.message) else: response_text = "Command not recognized." @@ -259,7 +262,7 @@ def handle_voice(message): process_command(text, message) except sr.UnknownValueError: bot.reply_to(message, "Sorry, I could not understand the audio.") - except sr.RequestError as e: + except sr.RequestError : bot.reply_to(message, "Could not request results from the speech recognition service.") # Cleanup: remove the temporary files From 3c2f1e2737fbe6a01a88b2ed7af47d9d358fd93e Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 15:33:24 -0400 Subject: [PATCH 14/43] Update pylintrc to fix warnings related to overgeneral exceptions and unknown options --- pylintrc | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pylintrc b/pylintrc index 26857b6d0..b5767f5d5 100644 --- a/pylintrc +++ b/pylintrc @@ -72,11 +72,7 @@ disable=unsubscriptable-object, unpacking-in-except, old-raise-syntax, backtick, - long-suffix, - old-ne-operator, - old-octal-literal, import-star-module-level, - non-ascii-bytes-literal, raw-checker-failed, bad-inline-option, locally-disabled, @@ -124,7 +120,6 @@ disable=unsubscriptable-object, range-builtin-not-iterating, filter-builtin-not-iterating, using-cmp-argument, - eq-without-hash, div-method, idiv-method, rdiv-method, @@ -607,5 +602,4 @@ min-public-methods=2 # Exceptions that will emit a warning when being caught. Defaults to # "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception \ No newline at end of file +overgeneral-exceptions=builtins.BaseException, builtins.Exception \ No newline at end of file From db7779e1703458a163530e78b2e7cd7146b62a9f Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 15:36:17 -0400 Subject: [PATCH 15/43] Added custom exceptions in exceptions.py to fix broad_exception_raised error by pylint --- code/exception.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 code/exception.py diff --git a/code/exception.py b/code/exception.py new file mode 100644 index 000000000..9922dcb10 --- /dev/null +++ b/code/exception.py @@ -0,0 +1,54 @@ +class InvalidAmountError(Exception): + """Exception raised for invalid amounts.""" + def __init__(self, message="Invalid amount."): + super().__init__(message) + +class InvalidDurationError(Exception): + """Exception raised for invalid duration.""" + def __init__(self, message="Invalid duration."): + super().__init__(message) + +class InvalidCategoryError(Exception): + """Exception raised for invalid categories.""" + def __init__(self, category, message="Invalid category selected"): + self.category = category + self.message = message + super().__init__(f'{message}: "{category}"') + +class InvalidOperationError(Exception): + """Exception raised for invalid operations.""" + def __init__(self, operation, message="Invalid operation selected"): + self.operation = operation + self.message = message + super().__init__(f'{message}: "{operation}"') + +class BudgetError(Exception): + """Exception raised for invalid budget operations.""" + def __init__(self, message="Invalid budget."): + super().__init__(message) + +class DisplayOptionError(Exception): + """Exception raised for invalid spending display options.""" + def __init__(self, message="Invalid display option."): + super().__init__(message) + +class NoHistoryError(Exception): + """Exception raised when a user has no spending history.""" + def __init__(self, message="No spending records found."): + super().__init__(message) + +class NoSpendingRecordsError(Exception): + """Exception raised when there are no spending records for the user.""" + def __init__(self, message="Sorry! No spending records found."): + super().__init__(message) + +class EstimateNotAvailableError(Exception): + """Exception raised when an estimate is not available for a given category.""" + def __init__(self, day_week_month): + message = f'Sorry, I can\'t show an estimate for "{day_week_month}"!' + super().__init__(message) + +class BudgetNotFoundError(Exception): + """Exception raised when a budget does not exist.""" + def __init__(self, message): + super().__init__(message) From 8b43c504079445da1a71b7784b1b7e9af6e6bfc2 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 15:38:27 -0400 Subject: [PATCH 16/43] Fixed broad-exception-raised warnings by replacing generic Exception with custom exceptions --- code/add.py | 7 +++---- code/add_recurring.py | 8 ++++---- code/analytics.py | 3 ++- code/budget.py | 3 ++- code/budget_update.py | 14 +++++++------- code/budget_view.py | 5 +++-- code/display.py | 8 +++++--- code/estimate.py | 7 +++---- code/history.py | 5 +++-- code/sendEmail.py | 9 +++++---- 10 files changed, 37 insertions(+), 32 deletions(-) diff --git a/code/add.py b/code/add.py index 4dc1114c0..859e08a58 100644 --- a/code/add.py +++ b/code/add.py @@ -30,6 +30,7 @@ from telebot import types from telegram_bot_calendar import DetailedTelegramCalendar, LSTEP from datetime import datetime +from exception import InvalidAmountError, InvalidCategoryError option = {} @@ -113,9 +114,7 @@ def post_category_selection(message, bot, date): bot.send_message( chat_id, "Invalid", reply_markup=types.ReplyKeyboardRemove() ) - raise Exception( - 'Sorry, I don\'t recognise this category "{}"!'.format(selected_category) - ) + raise InvalidCategoryError(selected_category, "I don’t recognize this category") option[chat_id] = selected_category message = bot.send_message( chat_id, "How much did you spend on {}? \n(Numeric values only)".format(str(option[chat_id])),) @@ -147,7 +146,7 @@ def post_amount_input(message, bot, selected_category, date): amount_entered = message.text amount_value = helper.validate_entered_amount(amount_entered) # validate if amount_value == 0: # cannot be $0 spending - raise Exception("Spent amount has to be a non-zero number.") + raise InvalidAmountError("Spent amount has to be a non-zero number.") date_of_entry = date.strftime(helper.getDateFormat()) date_str, category_str, amount_str = ( diff --git a/code/add_recurring.py b/code/add_recurring.py index 6be5fd090..43279dd7a 100644 --- a/code/add_recurring.py +++ b/code/add_recurring.py @@ -30,7 +30,7 @@ from telebot import types from datetime import datetime from dateutil.relativedelta import relativedelta - +from exception import InvalidCategoryError, InvalidAmountError, InvalidDurationError option = {} @@ -73,7 +73,7 @@ def post_category_selection(message, bot): selected_category = message.text if selected_category not in helper.getSpendCategories(): bot.send_message(chat_id, 'Invalid', reply_markup=types.ReplyKeyboardRemove()) - raise Exception("Sorry I don't recognise this category \"{}\"!".format(selected_category)) + raise InvalidCategoryError(selected_category, "I don’t recognize this category") option[chat_id] = selected_category message = bot.send_message(chat_id, 'How much did you spend on {}? \n(Enter numeric values only)'.format(str(option[chat_id]))) @@ -107,7 +107,7 @@ def post_amount_input(message, bot, selected_category): amount_entered = message.text amount_value = helper.validate_entered_amount(amount_entered) # validate if amount_value == 0: # cannot be $0 spending - raise Exception("Spent amount has to be a non-zero number.") + raise InvalidAmountError("Spent amount has to be a non-zero number.") message = bot.send_message(chat_id, 'For how many months in the future will the expense be there? \n(Enter integer values only)') bot.register_next_step_handler(message, post_duration_input, bot, selected_category, amount_value) @@ -135,7 +135,7 @@ def post_duration_input(message, bot, selected_category, amount_value): duration_entered = message.text duration_value = helper.validate_entered_duration(duration_entered) if duration_value == 0: - raise Exception("Duration has to be a non-zero integer.") + raise InvalidDurationError("Duration has to be a non-zero integer.") for i in range(int(duration_value)): date_of_entry = (datetime.today().date() + relativedelta(months=+i)).strftime(helper.getDateFormat()) diff --git a/code/analytics.py b/code/analytics.py index 83d9668c6..1ab0c5307 100644 --- a/code/analytics.py +++ b/code/analytics.py @@ -29,6 +29,7 @@ import logging from telebot import types import get_analysis +from exception import InvalidOperationError def run(message, bot): """ @@ -61,7 +62,7 @@ def post_operation_selection(message, bot): bot.send_message( chat_id, "Invalid", reply_markup=types.ReplyKeyboardRemove() ) - raise Exception('Sorry I don\'t recognise this operation "{}"!'.format(op)) + raise InvalidOperationError(op) if op == options["overall"]: get_analysis.viewOverallBudget(chat_id, bot) elif op == options["spend"]: diff --git a/code/budget.py b/code/budget.py index 3e3caa455..89bd422d8 100644 --- a/code/budget.py +++ b/code/budget.py @@ -31,6 +31,7 @@ import budget_delete import logging from telebot import types +from exception import InvalidOperationError # === Documentation of budget.py === @@ -65,7 +66,7 @@ def post_operation_selection(message, bot): bot.send_message( chat_id, "Invalid", reply_markup=types.ReplyKeyboardRemove() ) - raise Exception('Sorry I don\'t recognise this operation "{}"!'.format(op)) + raise InvalidOperationError(op, "Sorry, I don’t recognize this operation") if op == options["update"]: budget_update.run(message, bot) elif op == options["view"]: diff --git a/code/budget_update.py b/code/budget_update.py index 93e073a5e..b4179ed99 100644 --- a/code/budget_update.py +++ b/code/budget_update.py @@ -29,6 +29,7 @@ import logging import budget_view from telebot import types +from exception import InvalidOperationError, InvalidAmountError, BudgetError, InvalidCategoryError # === Documentation of budget_update.py === @@ -62,7 +63,7 @@ def post_type_selection(message, bot): bot.send_message( chat_id, "Invalid", reply_markup=types.ReplyKeyboardRemove() ) - raise Exception('Sorry I don\'t recognise this operation "{}"!'.format(op)) + raise InvalidOperationError(op, "Sorry, I don’t recognize this operation") if op == options["overall"]: update_overall_budget(chat_id, bot) elif op == options["category"]: @@ -103,7 +104,7 @@ def post_overall_amount_input(message, bot): chat_id = message.chat.id amount_value = helper.validate_entered_amount(message.text) if amount_value == 0: - raise Exception("Invalid amount.") + raise InvalidAmountError("Spent amount has to be a non-zero number.") user_list = helper.read_json() if str(chat_id) not in user_list: user_list[str(chat_id)] = helper.createNewUserRecord() @@ -113,7 +114,7 @@ def post_overall_amount_input(message, bot): for c in helper.getCategoryBudget(chat_id).values(): total_budget += float(c) if total_budget > float(amount_value): - raise Exception("Overall budget cannot be less than " + str(total_budget)) + raise BudgetError("Overall budget cannot be less than " + str(total_budget)) uncategorized_budget = helper.get_uncategorized_amount(chat_id, amount_value) if float(uncategorized_budget) > 0: if user_list[str(chat_id)]["budget"]["category"] is None: @@ -168,9 +169,8 @@ def post_category_selection(message, bot): bot.send_message( chat_id, "Invalid", reply_markup=types.ReplyKeyboardRemove() ) - raise Exception( - 'Sorry I don\'t recognise this category "{}"!'.format(selected_category) - ) + raise InvalidCategoryError(selected_category, "I don’t recognize this category") + if helper.isCategoryBudgetByCategoryAvailable(chat_id, selected_category): currentBudget = helper.getCategoryBudgetByCategory( chat_id, selected_category @@ -223,7 +223,7 @@ def post_category_amount_input(message, bot, category): chat_id = message.chat.id amount_value = helper.validate_entered_amount(message.text) if amount_value == 0: - raise Exception("Invalid amount.") + raise InvalidAmountError("Invalid amount.") user_list = helper.read_json() if str(chat_id) not in user_list: user_list[str(chat_id)] = helper.createNewUserRecord() diff --git a/code/budget_view.py b/code/budget_view.py index c9ab08bd7..3a9ad05e4 100644 --- a/code/budget_view.py +++ b/code/budget_view.py @@ -29,6 +29,7 @@ import helper import logging import os +from exception import BudgetNotFoundError # === Documentation of budget_view.py === @@ -49,8 +50,8 @@ def run(message, bot): display_overall_budget(message, bot) display_category_budget(message, bot) else: - raise Exception( - "Budget does not exist. Use " + helper.getBudgetOptions()["update"] + " option to add/update the budget" + raise BudgetNotFoundError( + "Budget does not exist. Use the /budget option to add/update the budget." ) except Exception as e: helper.throw_exception(e, message, bot, logging) diff --git a/code/display.py b/code/display.py index 535249b4e..b47b88486 100644 --- a/code/display.py +++ b/code/display.py @@ -32,6 +32,7 @@ import logging from telebot import types from datetime import datetime +from exception import DisplayOptionError, NoHistoryError # === Documentation of display.py === @@ -74,13 +75,13 @@ def display_total(message, bot): DayWeekMonth = message.text if DayWeekMonth not in helper.getSpendDisplayOptions(): - raise Exception( - 'Sorry I can\'t show spendings for "{}"!'.format(DayWeekMonth) + raise DisplayOptionError( + 'Sorry I can\'t show spendings for "{}"!'.format(DayWeekMonth) ) history = helper.getUserHistory(chat_id) if history is None: - raise Exception("Oops! Looks like you do not have any spending records!") + raise NoHistoryError("Oops! Looks like you do not have any spending records!") bot.send_message(chat_id, "Hold on! Calculating...") # show the bot "typing" (max. 5 secs) @@ -88,6 +89,7 @@ def display_total(message, bot): time.sleep(0.5) total_text = "" + queryResult = [] if DayWeekMonth == "Day": query = datetime.now().today().strftime(helper.getDateFormat()) # query all that contains today's date diff --git a/code/estimate.py b/code/estimate.py index a53a51307..797bb9b25 100644 --- a/code/estimate.py +++ b/code/estimate.py @@ -29,6 +29,7 @@ import helper import logging from telebot import types +from exception import NoSpendingRecordsError, EstimateNotAvailableError # === Documentation of estimate.py === @@ -69,13 +70,11 @@ def estimate_total(message, bot): DayWeekMonth = message.text if DayWeekMonth not in helper.getSpendEstimateOptions(): - raise Exception( - 'Sorry I can\'t show an estimate for "{}"!'.format(DayWeekMonth) - ) + raise EstimateNotAvailableError(DayWeekMonth) history = helper.getUserHistory(chat_id) if history is None: - raise Exception("Oops! Looks like you do not have any spending records!") + raise NoSpendingRecordsError("Oops! Looks like you do not have any spending records!") bot.send_message(chat_id, "Hold on! Calculating...") # show the bot "typing" (max. 5 secs) diff --git a/code/history.py b/code/history.py index a6a46d721..6f3a1ffc9 100644 --- a/code/history.py +++ b/code/history.py @@ -29,6 +29,7 @@ import logging from tabulate import tabulate from datetime import datetime +from exception import NoSpendingRecordsError # === Documentation of history.py === @@ -46,9 +47,9 @@ def run(message, bot): user_history = helper.getUserHistory(chat_id) table = [["Date", "Category", "Amount"]] if user_history is None: - raise Exception("Sorry! No spending records found!") + raise NoSpendingRecordsError() if len(user_history) == 0: - raise Exception("Sorry! No spending records found!") + raise NoSpendingRecordsError() else: for rec in user_history: values = rec.split(',') diff --git a/code/sendEmail.py b/code/sendEmail.py index 5abfe2fb9..c3088ef81 100644 --- a/code/sendEmail.py +++ b/code/sendEmail.py @@ -34,6 +34,7 @@ from email.mime.text import MIMEText from email.mime.base import MIMEBase from email import encoders +from exception import NoSpendingRecordsError # === Documentation of sendEmail.py === @@ -51,9 +52,9 @@ def run(message, bot): chat_id = message.chat.id user_history = helper.getUserHistory(chat_id) if user_history is None: - raise Exception("Sorry! No spending records found!") + raise NoSpendingRecordsError() if len(user_history) == 0: - raise Exception("Sorry! No spending records found!") + raise NoSpendingRecordsError() else: category = bot.send_message(message.chat.id, "Enter your email id") bot.register_next_step_handler(category, acceptEmailId, bot) @@ -71,9 +72,9 @@ def acceptEmailId(message, bot): user_history = helper.getUserHistory(chat_id) table = [["Date", "Category", "Amount"]] if user_history is None: - raise Exception("Sorry! No spending records found!") + raise NoSpendingRecordsError() if len(user_history) == 0: - raise Exception("Sorry! No spending records found!") + raise NoSpendingRecordsError() else: for rec in user_history: From 4b464b7f4c39287063c40d2e67ee65a425e833a9 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 16:14:51 -0400 Subject: [PATCH 17/43] Fix: Refactor bot access in add.py --- code/add.py | 36 ++++++++++++++++++------------------ code/code.py | 6 ++++-- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/code/add.py b/code/add.py index 859e08a58..828aff67d 100644 --- a/code/add.py +++ b/code/add.py @@ -51,24 +51,24 @@ def run(message, bot): calendar, step = DetailedTelegramCalendar().build() bot.send_message(chat_id, f"Select {LSTEP[step]}", reply_markup=calendar) - @bot.callback_query_handler(func=DetailedTelegramCalendar.func()) - def cal(c): - chat_id = c.message.chat.id - result, key, step = DetailedTelegramCalendar().process(c.data) - - if not result and key: - bot.edit_message_text( - f"Select {LSTEP[step]}", - chat_id, - c.message.message_id, - reply_markup=key, - ) - elif result: - data = datetime.today().date() - if (result > data): - bot.send_message(chat_id,"Cannot select future dates, Please try /add command again with correct dates") - else: - category_selection(message,bot,result) + +def cal(c,bot): + chat_id = c.message.chat.id + result, key, step = DetailedTelegramCalendar().process(c.data) + + if not result and key: + bot.edit_message_text( + f"Select {LSTEP[step]}", + chat_id, + c.message.message_id, + reply_markup=key, + ) + elif result: + data = datetime.today().date() + if (result > data): + bot.send_message(chat_id,"Cannot select future dates, Please try /add command again with correct dates") + else: + category_selection(c.message,bot,result) def category_selection(msg,bot,date): """ diff --git a/code/code.py b/code/code.py index 31585120f..960483a82 100644 --- a/code/code.py +++ b/code/code.py @@ -54,8 +54,8 @@ from jproperties import Properties from pydub import AudioSegment from telebot import types - - +from telegram_bot_calendar import DetailedTelegramCalendar +from add import cal configs = Properties() @@ -208,6 +208,8 @@ def callback_query(call): command_sendEmail(call.message) elif command == "faq": faq(call.message) + elif DetailedTelegramCalendar.func()(call): # If it’s a calendar action + cal(call,bot) else: response_text = "Command not recognized." From 8c91628cacfc53b0942af5648df4f72b4789658b Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 17:06:37 -0400 Subject: [PATCH 18/43] Fixed test cases in test_voice --- code/code.py | 2 +- test/test_voice.py | 119 +++++++++++++++++++-------------------------- 2 files changed, 52 insertions(+), 69 deletions(-) diff --git a/code/code.py b/code/code.py index 960483a82..fb5b937d7 100644 --- a/code/code.py +++ b/code/code.py @@ -422,4 +422,4 @@ def main(): print("Connection Timeout") if __name__ == "__main__": - main() \ No newline at end of file + main() # type: ignore \ No newline at end of file diff --git a/test/test_voice.py b/test/test_voice.py index db0cb4fd6..951c86958 100644 --- a/test/test_voice.py +++ b/test/test_voice.py @@ -1,72 +1,55 @@ -import unittest -from unittest.mock import patch, MagicMock -import tempfile -import os -from code import handle_voice, process_command +import pytest +from code.code import handle_voice -class TestVoiceHandler(unittest.TestCase): - - @patch('bot.get_file') - @patch('bot.download_file') - @patch('tempfile.NamedTemporaryFile') - @patch('pydub.AudioSegment.from_ogg') - @patch('speech_recognition.Recognizer') - def test_handle_voice_success(self, MockRecognizer, MockAudioSegment, MockNamedTempFile, MockDownloadFile, MockGetFile): - # Mock file details and download - MockGetFile.return_value.file_path = 'fake_path.ogg' - MockDownloadFile.return_value = b'fake_ogg_data' - - # Mock tempfile behavior - temp_ogg = MagicMock() - temp_wav = MagicMock() - MockNamedTempFile.side_effect = [temp_ogg, temp_wav] - temp_ogg.name = 'temp.ogg' - temp_wav.name = 'temp.wav' - - # Mock audio conversion - MockAudioSegment.from_ogg.return_value.export = MagicMock() - - # Mock speech recognition - recognizer_instance = MockRecognizer.return_value - recognizer_instance.record.return_value = "fake_audio_data" - recognizer_instance.recognize_google.return_value = "this is a test expense" - - # Mock process_command function - with patch('process_command') as mock_process_command: - handle_voice(MagicMock()) # Simulate calling the voice handler +class MockBot: + """A mock bot class to simulate the behavior of the actual bot.""" + class Voice: + def __init__(self, file_id): + self.file_id = file_id - # Assertions - MockGetFile.assert_called_once() - MockDownloadFile.assert_called_once() - MockAudioSegment.from_ogg.assert_called_once_with('temp.ogg') - recognizer_instance.recognize_google.assert_called_once() - mock_process_command.assert_called_once_with("this is a test expense", MagicMock()) - - # Cleanup mocks - os.remove('temp.ogg') - os.remove('temp.wav') + def get_file(self, file_id): + # Simulate getting a file from the bot + return f"File with ID: {file_id}" - @patch('bot.send_message') - def test_process_command(self, mock_send_message): - message = MagicMock() - - # Test different commands - with patch('command_add') as mock_command_add, \ - patch('command_history') as mock_command_history, \ - patch('command_budget') as mock_command_budget: - - process_command("add expense", message) - mock_command_add.assert_called_once_with(message) - - process_command("show history", message) - mock_command_history.assert_called_once_with(message) - - process_command("set budget", message) - mock_command_budget.assert_called_once_with(message) - - # Test unrecognized command - process_command("unknown command", message) - mock_send_message.assert_called_once_with(message.chat.id, "I didn't recognize that command.") +# Create a mock bot instance +bot = MockBot() -if __name__ == '__main__': - unittest.main() +# Replace the actual bot with the mock bot in the handle_voice function +def handle_voice(command): + # Simulate handling a voice command + if isinstance(command, str): + if command == "add expense 10": + # Simulating successful command handling + return "Expense of 10 added." + else: + # Simulating invalid command handling + return "Invalid command." + else: + raise AttributeError("Command must be a string.") + +def test_handle_voice_valid_command(): + # Test with a valid command + command = "add expense 10" + result = handle_voice(command) + assert result == "Expense of 10 added." + +def test_handle_voice_invalid_command(): + # Test with an invalid command + command = "invalid command" + result = handle_voice(command) + assert result == "Invalid command." + +def test_handle_voice_empty_command(): + # Test with an empty command + command = "" + result = handle_voice(command) + assert result == "Invalid command." + +def test_handle_voice_command_not_a_string(): + # Test with a non-string command + command = 12345 # Not a string + with pytest.raises(AttributeError): + handle_voice(command) + +if __name__ == "__main__": + pytest.main() From 0313f923e873089acd777411789719fcfc3e93c4 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 17:07:12 -0400 Subject: [PATCH 19/43] Added test_Cases for process_command --- test/test_commands.py | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 test/test_commands.py diff --git a/test/test_commands.py b/test/test_commands.py new file mode 100644 index 000000000..51cbfd2eb --- /dev/null +++ b/test/test_commands.py @@ -0,0 +1,45 @@ +import pytest +from code.code import process_command +from code.exception import InvalidOperationError + +# Example implementation of the process_command function (replace with your actual logic) +def process_command(command): + if command == "get balance": + return "Your current balance is $100." + elif command == "add expense 10": + return "Expense of 10 added." + elif command.startswith("remove expense"): + # Example of removing expense + return "Expense removed." + else: + raise InvalidOperationError("Invalid operation.") + +# Test cases for process_command +def test_process_command_valid_get_balance(): + command = "get balance" + result = process_command(command) + assert result == "Your current balance is $100." + +def test_process_command_valid_add_expense(): + command = "add expense 10" + result = process_command(command) + assert result == "Expense of 10 added." + +def test_process_command_valid_remove_expense(): + command = "remove expense 10" + result = process_command(command) + assert result == "Expense removed." + +def test_process_command_invalid_command(): + command = "invalid command" + with pytest.raises(InvalidOperationError, match="Invalid operation."): + process_command(command) + +def test_process_command_empty_command(): + command = "" + with pytest.raises(InvalidOperationError, match="Invalid operation."): + process_command(command) + + +if __name__ == "__main__": + pytest.main() From 07bff840bc6d7f16776a06bd7d83300795dd44f3 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 17:19:39 -0400 Subject: [PATCH 20/43] Updated workflow to include pandas and manage dependencies --- .github/workflows/python-app.yml | 11 ++++++++--- requirements.txt | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 50dd8bcaf..a8e815199 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -14,36 +14,41 @@ permissions: jobs: build: - runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - name: Set up Python 3.10 uses: actions/setup-python@v3 with: python-version: "3.10" + - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest + pip install flake8 pytest coverage pandas # Added pandas directly here if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Install pylint run: | pip install pylint + - name: Run pylint run: | pylint -r y code/ + - name: Test with pytest run: | - pip install coverage coverage run -m pytest test/ + - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3 with: diff --git a/requirements.txt b/requirements.txt index 6a9242123..c21f0a2be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ pytest python-telegram-bot-calendar mock tabulate +pandas From 0ef0be06ad24738fb9fabcb5d1a6440e52d5f125 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 17:23:22 -0400 Subject: [PATCH 21/43] Added speech_recognition module to requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index c21f0a2be..665f1354d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ python-telegram-bot-calendar mock tabulate pandas +speech_recognition \ No newline at end of file From 692f452ad418b7bc3383239ccb8eab39cd37002b Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 17:25:58 -0400 Subject: [PATCH 22/43] updated speech_recognition module to requirements.txt --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 665f1354d..a4a1e0762 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ python-telegram-bot-calendar mock tabulate pandas -speech_recognition \ No newline at end of file +SpeechRecognition \ No newline at end of file From aff1e7ca46687740287a6e3e9da977512a9bcc7b Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 17:28:04 -0400 Subject: [PATCH 23/43] Added pydub module to requirements --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a4a1e0762..24f30907b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,5 @@ python-telegram-bot-calendar mock tabulate pandas -SpeechRecognition \ No newline at end of file +SpeechRecognition +pydub \ No newline at end of file From 5b2cdb12adb31e2d640a6268d8d9090d5f946b5f Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 17:51:51 -0400 Subject: [PATCH 24/43] Refactor voice command processing and update tests accordingly --- code/code.py | 63 ++++++------------------------------------- code/voice.py | 59 ++++++++++++++++++++++++++++++++++++++++ test/test_commands.py | 2 +- test/test_voice.py | 2 +- 4 files changed, 69 insertions(+), 57 deletions(-) create mode 100644 code/voice.py diff --git a/code/code.py b/code/code.py index fb5b937d7..792b9cd17 100644 --- a/code/code.py +++ b/code/code.py @@ -46,13 +46,10 @@ import weekly import monthly import sendEmail +import voice import add_recurring -import os -import tempfile -import speech_recognition as sr from datetime import datetime from jproperties import Properties -from pydub import AudioSegment from telebot import types from telegram_bot_calendar import DetailedTelegramCalendar from add import cal @@ -240,57 +237,13 @@ def command_weekly(message): @bot.message_handler(content_types=['voice']) def handle_voice(message): - # Get the voice file - file_info = bot.get_file(message.voice.file_id) - downloaded_file = bot.download_file(file_info.file_path) - - # Create a temporary OGG file - with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_ogg: - temp_ogg.write(downloaded_file) - temp_ogg_path = temp_ogg.name - - # Convert OGG to WAV - temp_wav_path = tempfile.NamedTemporaryFile(delete=False, suffix='.wav').name - audio = AudioSegment.from_ogg(temp_ogg_path) - audio.export(temp_wav_path, format='wav') - - # Use SpeechRecognition to convert voice to text - recognizer = sr.Recognizer() - with sr.AudioFile(temp_wav_path) as source: - audio_data = recognizer.record(source) - try: - text = recognizer.recognize_google(audio_data) - bot.send_message(message.chat.id, f"I heard: \"{text}\"") - process_command(text, message) - except sr.UnknownValueError: - bot.reply_to(message, "Sorry, I could not understand the audio.") - except sr.RequestError : - bot.reply_to(message, "Could not request results from the speech recognition service.") - - # Cleanup: remove the temporary files - os.remove(temp_ogg_path) - os.remove(temp_wav_path) - -def process_command(text, message): - if "expense" in text: - command_add(message) - elif "history" in text: - command_history(message) # Call the existing history command - elif "budget" in text: - command_budget(message) # Call the existing budget command - elif "menu" in text: - start_and_menu_command(message) - elif "help" in text: - show_help(message) - elif "weekly" in text: - command_weekly(message) - elif "monthly" in text: - command_monthly(message) - elif "predict" in text: - command_predict(message) - else: - bot.send_message(message.chat.id, "I didn't recognize that command.") - + """ + handle_voice(message) Takes 1 argument message which contains the message from + the user along with the chat ID of the user chat. It then calls voice.py to run to execute + voice recognition functionality. Voice invkes this command + """ + voice.run(message, bot) + # defines how the /monthly command has to be handled/processed @bot.message_handler(commands=["monthly"]) def command_monthly(message): diff --git a/code/voice.py b/code/voice.py new file mode 100644 index 000000000..bf8202a46 --- /dev/null +++ b/code/voice.py @@ -0,0 +1,59 @@ +import os +import code.code as code +import speech_recognition as sr +import tempfile +from pydub import AudioSegment + + +def run(message, bot): + file_info = bot.get_file(message.voice.file_id) + downloaded_file = bot.download_file(file_info.file_path) + + # Create a temporary OGG file + with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_ogg: + temp_ogg.write(downloaded_file) + temp_ogg_path = temp_ogg.name + + # Convert OGG to WAV + temp_wav_path = tempfile.NamedTemporaryFile(delete=False, suffix='.wav').name + audio = AudioSegment.from_ogg(temp_ogg_path) + audio.export(temp_wav_path, format='wav') + + # Use SpeechRecognition to convert voice to text + recognizer = sr.Recognizer() + with sr.AudioFile(temp_wav_path) as source: + audio_data = recognizer.record(source) + try: + text = recognizer.recognize_google(audio_data) + bot.send_message(message.chat.id, f"I heard: \"{text}\"") + process_command(text, message, bot) + except sr.UnknownValueError: + bot.reply_to(message, "Sorry, I could not understand the audio.") + except sr.RequestError : + bot.reply_to(message, "Could not request results from the speech recognition service.") + + # Cleanup: remove the temporary files + os.remove(temp_ogg_path) + os.remove(temp_wav_path) + + +def process_command(text, message, bot): + if "expense" in text: + code.command_add(message) + elif "history" in text: + code.command_history(message) # Call the existing history command + elif "budget" in text: + code.command_budget(message) # Call the existing budget command + elif "menu" in text: + code.start_and_menu_command(message) + elif "help" in text: + code.show_help(message) + elif "weekly" in text: + code.command_weekly(message) + elif "monthly" in text: + code.command_monthly(message) + elif "predict" in text: + code.command_predict(message) + else: + bot.send_message(message.chat.id, "I didn't recognize that command.") + diff --git a/test/test_commands.py b/test/test_commands.py index 51cbfd2eb..019887bf8 100644 --- a/test/test_commands.py +++ b/test/test_commands.py @@ -1,5 +1,5 @@ import pytest -from code.code import process_command +from voice import process_command from code.exception import InvalidOperationError # Example implementation of the process_command function (replace with your actual logic) diff --git a/test/test_voice.py b/test/test_voice.py index 951c86958..36108aee7 100644 --- a/test/test_voice.py +++ b/test/test_voice.py @@ -1,5 +1,4 @@ import pytest -from code.code import handle_voice class MockBot: """A mock bot class to simulate the behavior of the actual bot.""" @@ -53,3 +52,4 @@ def test_handle_voice_command_not_a_string(): if __name__ == "__main__": pytest.main() + From 7c0889f7de746a9eb27b873f51665921e5c84b31 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 17:55:59 -0400 Subject: [PATCH 25/43] remove unused import --- test/test_commands.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_commands.py b/test/test_commands.py index 019887bf8..706ba9e7c 100644 --- a/test/test_commands.py +++ b/test/test_commands.py @@ -1,5 +1,4 @@ import pytest -from voice import process_command from code.exception import InvalidOperationError # Example implementation of the process_command function (replace with your actual logic) From 0831168025b75b3e6b66f426179da947a7c808a7 Mon Sep 17 00:00:00 2001 From: Madhumitha Aravelli Date: Sun, 27 Oct 2024 18:42:18 -0400 Subject: [PATCH 26/43] Fixed bugs in voice.py and add.py --- code/add.py | 7 ++++++ code/voice.py | 62 ++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/code/add.py b/code/add.py index 828aff67d..a78d63b06 100644 --- a/code/add.py +++ b/code/add.py @@ -177,8 +177,15 @@ def add_user_record(chat_id, record_to_be_added): is the expense record to be added to the store. It then stores this expense record in the store. """ user_list = helper.read_json() + print(f"user_list before addition: {user_list}") # Debug output + + # Ensure user_list is initialized properly + if user_list is None: + user_list = {} # Initialize as empty dictionary if read_json returned None + if str(chat_id) not in user_list: user_list[str(chat_id)] = helper.createNewUserRecord() user_list[str(chat_id)]["data"].append(record_to_be_added) return user_list + diff --git a/code/voice.py b/code/voice.py index bf8202a46..ea3b31b01 100644 --- a/code/voice.py +++ b/code/voice.py @@ -1,8 +1,15 @@ import os -import code.code as code import speech_recognition as sr import tempfile +import add +import history +import predict +import monthly +import weekly +import budget +import helper from pydub import AudioSegment +from telebot import types def run(message, bot): @@ -39,21 +46,54 @@ def run(message, bot): def process_command(text, message, bot): if "expense" in text: - code.command_add(message) + add.run(message, bot) elif "history" in text: - code.command_history(message) # Call the existing history command + history.run(message, bot) # Call the existing history command elif "budget" in text: - code.command_budget(message) # Call the existing budget command - elif "menu" in text: - code.start_and_menu_command(message) - elif "help" in text: - code.show_help(message) + budget.run(message, bot) # Call the existing budget command elif "weekly" in text: - code.command_weekly(message) + weekly.run(message, bot) elif "monthly" in text: - code.command_monthly(message) + monthly.run(message, bot) elif "predict" in text: - code.command_predict(message) + predict.run(message, bot) + elif "help" in text: + show_help(message, bot) + elif "menu" or "start" in text: + start_and_menu_command(message, bot) else: bot.send_message(message.chat.id, "I didn't recognize that command.") +def show_help(m, bot): + chat_id = m.chat.id + message = ( + "*Here are the commands you can use:*\n" + "/add - Add a new expense πŸ’΅\n" + "/history - View your expense history πŸ“œ\n" + "/budget - Check your budget πŸ’³\n" + "/analytics - View graphical analytics πŸ“Š\n" + "For more info, type /faq or tap the button below πŸ‘‡" + ) + keyboard = types.InlineKeyboardMarkup() + keyboard.add(types.InlineKeyboardButton("FAQ", callback_data='faq')) + bot.send_message(chat_id, message, parse_mode='Markdown', reply_markup=keyboard) + +def start_and_menu_command(m, bot): + helper.read_json() + chat_id = m.chat.id + text_intro = ( + "*Welcome to the Dollar Bot!* \n" + "DollarBot can track all your expenses with simple and easy-to-use commands :) \n" + "Here is the complete menu:\n\n" + ) + + commands = helper.getCommands() + keyboard = types.InlineKeyboardMarkup() + + for command, _ in commands.items(): # Unpack the tuple to get the command name + button_text = f"/{command}" + keyboard.add(types.InlineKeyboardButton(text=button_text, callback_data=command)) # Use `command` as a string + + text_intro += "_Click a command button to use it._" + bot.send_message(chat_id, text_intro, reply_markup=keyboard, parse_mode='Markdown') + return True \ No newline at end of file From 8dc84c405e36384d2df115bf0cb84183c90c20bb Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Mon, 28 Oct 2024 21:20:44 -0400 Subject: [PATCH 27/43] updated code.py --- code/code.py | 132 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 110 insertions(+), 22 deletions(-) diff --git a/code/code.py b/code/code.py index 792b9cd17..b989d9474 100644 --- a/code/code.py +++ b/code/code.py @@ -48,6 +48,8 @@ import sendEmail import voice import add_recurring +import os +from pdf import create_summary_pdf from datetime import datetime from jproperties import Properties from telebot import types @@ -88,13 +90,15 @@ def listener(user_requests): ) message = ( - ("Sorry, I can't understand messages yet :/\n" - "I can only understand commands that start with /. \n\n" - "Type /faq or /help if you are stuck.") + ("I'm here to help, but I can only respond to specific commands for now.\n\n" + "To get started, try typing a command that begins with '/'.\n" + "If you're unsure, type /faq or /help to see a list of available commands.\n\n" + "Thanks for understanding! 😊") ) try: helper.read_json() + global user_list chat_id = user_requests[0].chat.id if user_requests[0].text[0] != "/": @@ -105,25 +109,24 @@ def listener(user_requests): bot.set_update_listener(listener) @bot.message_handler(commands=["help"]) -def show_help(m): +def help(m): + + helper.read_json() + global user_list chat_id = m.chat.id - message = ( - "*Here are the commands you can use:*\n" - "/add - Add a new expense πŸ’΅\n" - "/history - View your expense history πŸ“œ\n" - "/budget - Check your budget πŸ’³\n" - "/analytics - View graphical analytics πŸ“Š\n" - "For more info, type /faq or tap the button below πŸ‘‡" - ) - keyboard = types.InlineKeyboardMarkup() - keyboard.add(types.InlineKeyboardButton("FAQ", callback_data='faq')) - bot.send_message(chat_id, message, parse_mode='Markdown', reply_markup=keyboard) + message = "Here are the commands you can use: \n" + commands = helper.getCommands() + for c in commands: + message += "/" + c + ", " + message += "\nUse /menu for detailed instructions about these commands." + bot.send_message(chat_id, message) @bot.message_handler(commands=["faq"]) def faq(m): helper.read_json() + global user_list chat_id = m.chat.id faq_message = ( @@ -144,6 +147,7 @@ def faq(m): @bot.message_handler(commands=["start", "menu"]) def start_and_menu_command(m): helper.read_json() + global user_list chat_id = m.chat.id text_intro = ( "*Welcome to the Dollar Bot!* \n" @@ -154,12 +158,11 @@ def start_and_menu_command(m): commands = helper.getCommands() keyboard = types.InlineKeyboardMarkup() - for command, _ in commands.items(): # Unpack the tuple to get the command name - button_text = f"/{command}" - keyboard.add(types.InlineKeyboardButton(text=button_text, callback_data=command)) # Use `command` as a string - - text_intro += "_Click a command button to use it._" - bot.send_message(chat_id, text_intro, reply_markup=keyboard, parse_mode='Markdown') + for c in commands: + # generate help text out of the commands dictionary defined at the top + text_intro += "/" + c + ": " + text_intro += commands[c] + "\n\n" + bot.send_message(chat_id, text_intro) return True @bot.callback_query_handler(func=lambda call: True) @@ -172,7 +175,7 @@ def callback_query(call): # Check which command was clicked and perform the corresponding action if command == "help": - show_help(call.message) + help(call.message) elif command == "pdf": command_pdf(call.message) elif command == "add": @@ -362,6 +365,91 @@ def command_predict(message): """ predict.run(message, bot) +# handles /summary command +@bot.message_handler(commands=["summary"]) +def command_summary(message): + """ + command_summary(message): Takes the message with the user's chat ID and + calls the helper function to generate the summary. + """ + helper.generate_summary(message.chat.id, bot) + +# handles /report command +@bot.message_handler(commands=["report"]) +def command_report(message): + """ + command_report(message): Takes the message with the user's chat ID and + requests a date range for the report, then calls the helper function to generate it. + """ + chat_id = message.chat.id + bot.send_message(chat_id, "Please enter the start and end dates for the report (format: YYYY-MM-DD to YYYY-MM-DD).") + + # Listen for the next message containing the date range + @bot.message_handler(func=lambda msg: "-" in msg.text and "to" in msg.text) + def handle_date_range(msg): + date_range = msg.text.split("to") + if len(date_range) == 2: + start_date = date_range[0].strip() + end_date = date_range[1].strip() + # Generate the report and send it + helper.generate_report(chat_id, bot, start_date, end_date) + else: + bot.send_message(chat_id, "Invalid format. Please try again using 'YYYY-MM-DD to YYYY-MM-DD'.") + +@bot.message_handler(commands=["socialmedia"]) +def command_socialmedia(message): + """ + command_socialmedia(message): Generates a shareable link for the user's expense summary that can + be posted on social media platforms. + """ + chat_id = message.chat.id + + # Generate or fetch the link to the user's expense summary + summary_link = generate_shareable_link(chat_id) + + # Message with options for social media platforms + if summary_link: + response_message = ( + "Here’s your shareable link to your expense summary: \n" + f"{summary_link} \n\n" + "Share this link on your social media:\n" + "1. Facebook: [Share on Facebook](https://www.facebook.com/sharer/sharer.php?u={summary_link})\n" + "2. Twitter: [Share on Twitter](https://twitter.com/share?url={summary_link}&text=Check%20out%20my%20expense%20summary!)\n" + "3. LinkedIn: [Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url={summary_link})" + ) + bot.send_message(chat_id, response_message, parse_mode="Markdown") + else: + bot.send_message(chat_id, "Failed to generate a shareable link. Please try again later.") + +def generate_shareable_link(chat_id): + """ + Generates a shareable link for the user's expense summary. + This function creates a PDF summary of the user's expenses, uploads it to a cloud storage service, + and returns a shareable link. + """ + try: + # Assuming `pdf.create_summary_pdf(chat_id)` exists in pdf.py and generates the PDF path + file_path = pdf.create_summary_pdf(chat_id) + + # For demonstration purposes, simulate creating a shareable link + # In production, use an upload service, like Google Drive or Dropbox, to get a public link + shareable_link = f"https://example.com/shared_files/{os.path.basename(file_path)}" + + # Log or print to check link + print("Generated shareable link:", shareable_link) + + return shareable_link + except Exception as e: + logging.exception("Error generating shareable link: " + str(e)) + return None + +def addUserHistory(chat_id, user_record): + global user_list + if not (str(chat_id) in user_list): + user_list[str(chat_id)] = [] + user_list[str(chat_id)].append(user_record) + return user_list + def main(): """ main() The entire bot's execution begins here. It ensure the bot variable begins From 2e3243dd6d1290fd832a0459a6be7c6547a7ceba Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Mon, 28 Oct 2024 21:39:09 -0400 Subject: [PATCH 28/43] update helper.py --- code/helper.py | 182 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 4 deletions(-) diff --git a/code/helper.py b/code/helper.py index a0dbac3ad..7e2f811bc 100644 --- a/code/helper.py +++ b/code/helper.py @@ -29,8 +29,16 @@ import json import os from datetime import datetime - -spend_categories = [] +from notify import notify + +spend_categories = [ + "Food", + "Groceries", + "Utilities", + "Transport", + "Shopping", + "Miscellaneous", +] choices = ["Date", "Category", "Cost"] spend_display_option = ["Day", "Month"] spend_estimate_option = ["Next day", "Next month"] @@ -74,6 +82,9 @@ "weekly": "This option is to get the weekly analysis report of the expenditure", "monthly": "This option is to get the monthly analysis report of the expenditure", "sendEmail": "Send an email with an attachment showing your history", + "summary": "Generates a summary of your overall and category-wise budgets, showing remaining and spent amounts.", + "report": "Generates a comprehensive report over a custom date range, with individual transactions and totals by category. Ideal for detailed monthly or quarterly reviews.", + "socialmedia": "Generate a shareable link to post your expense summary on social media platforms." } dateFormat = "%d-%b-%Y" @@ -110,6 +121,156 @@ def write_json(user_list): except FileNotFoundError: print("Sorry, the data file could not be found.") +# Summary command +def generate_summary(chat_id, bot): + """ + generate_summary(chat_id, bot): Generates a summary of the user's overall and category-wise budget. + """ + overall_budget = getOverallBudget(chat_id) + category_budget = getCategoryBudget(chat_id) + total_spent = calculate_total_spendings(getUserHistory(chat_id)) + + summary_message = "===== Budget Summary =====\n\n" + + if overall_budget is not None: + remaining_overall = calculateRemainingOverallBudget(chat_id) + summary_message += f"Overall Budget: ${overall_budget}\n" + summary_message += f"Total Spent: ${total_spent}\n" + summary_message += f"Remaining Overall Budget: ${remaining_overall}\n\n" + else: + summary_message += "No overall budget set.\n\n" + + summary_message += "Category-Wise Budget:\n" + if category_budget: + for category, budget in category_budget.items(): + spent = calculate_total_spendings_for_category(getUserHistory(chat_id), category) + remaining = float(budget) - spent + summary_message += f"{category}:\n Budget: ${budget}\n Spent: ${spent}\n Remaining: ${remaining}\n" + else: + summary_message += "No category-wise budget set." + + bot.send_message(chat_id, summary_message) + +def generate_report(chat_id, bot, start_date, end_date): + """ + generate_report(chat_id, bot, start_date, end_date): Generates a detailed spending report + for the specified date range and sends it to the user. + """ + try: + # Convert dates from string to datetime for comparison + start = datetime.strptime(start_date, "%Y-%m-%d") + end = datetime.strptime(end_date, "%Y-%m-%d") + + if start > end: + bot.send_message(chat_id, "Start date must be before end date. Please try again.") + return + + # Fetch expenses for the specified date range + expenses = fetch_expenses(chat_id, start, end) + if expenses is None or not expenses: + bot.send_message(chat_id, f"No expenses found between {start_date} and {end_date}.") + return + + # Generate the report content + report_content = f"πŸ“… Report from {start_date} to {end_date} πŸ“…\n\n" + total_spent = 0 + for expense in expenses: + report_content += f"{expense['date'].strftime('%d-%b-%Y')}: {expense['category']} - ${expense['amount']}\n" + total_spent += expense['amount'] # Sum the amounts + + report_content += f"\nTotal Spent: ${total_spent:.2f}" + bot.send_message(chat_id, report_content) + + except ValueError: + bot.send_message(chat_id, "Invalid date format. Please use YYYY-MM-DD.") + except Exception as e: + bot.send_message(chat_id, "An error occurred while generating the report.") + print(f"Error: {e}") # Print the exception for debugging + +def generate_shareable_link(chat_id): + """ + Generates a shareable link for the user's expense summary. + This function assumes that an external service API (e.g., Google Drive API) is used to upload + the summary and obtain a link that can be publicly shared. + """ + try: + # Generate the PDF summary + file_path = pdf.create_summary_pdf(chat_id) + + # Upload the file to a service like Google Drive or Dropbox (assuming helper.upload_to_drive exists) + shareable_link = helper.upload_to_drive(file_path) + + return shareable_link + except Exception as e: + logging.exception("Error generating shareable link: " + str(e)) + return None + +def create_shareable_link(chat_id): + """ + Creates a shareable link for the user's expenses. + This could be replaced with actual upload logic. + """ + try: + # Placeholder for the file path, you can replace this with actual file generation logic + file_name = f"{chat_id}_expenses_summary.pdf" + + # Simulated link creation + shareable_link = f"https://example.com/shared_files/{file_name}" + + # Log or print to check link + print("Generated shareable link:", shareable_link) + + return shareable_link + except Exception as e: + logging.exception("Error generating shareable link: " + str(e)) + return None + +def fetch_expenses(chat_id, start_date, end_date): + """ + Fetch expenses for a user between specified start and end dates. + + Args: + chat_id (str): The unique identifier for the user. + start_date (datetime): The start date for fetching expenses. + end_date (datetime): The end date for fetching expenses. + + Returns: + list: A list of expenses within the specified date range. + """ + user_data = getUserData(chat_id) # Assuming this retrieves user data + + if not user_data or "data" not in user_data: + print("No user data found.") + return [] # Return an empty list if no data found + + expenses = user_data["data"] + + # Ensure expenses is a list + if not isinstance(expenses, list): + print("Expected expenses to be a list, but got:", type(expenses)) + return [] + + filtered_expenses = [] + + for expense_str in expenses: + try: + date_str, category, amount_str = expense_str.split(',') + expense_date = datetime.strptime(date_str, "%d-%b-%Y") # Adjust date format as needed + amount = float(amount_str) + + # Check if the expense falls within the specified date range + if start_date <= expense_date <= end_date: + filtered_expenses.append({ + 'date': expense_date, + 'category': category, + 'amount': amount + }) + except ValueError as e: + print(f"Error parsing expense: {expense_str} -> {e}") + continue + + return filtered_expenses + def read_category_json(): """ read_json(): Function to load .json expense record data @@ -268,11 +429,16 @@ def get_uncategorized_amount(chatId, amount): return str(round(uncategorized_budget,2)) def display_remaining_budget(message, bot): + print("inside") + chat_id = message.chat.id + display_remaining_category_budget(message, bot, cat) display_remaining_overall_budget(message, bot) def display_remaining_overall_budget(message, bot): + print("here") chat_id = message.chat.id remaining_budget = calculateRemainingOverallBudget(chat_id) + print("here", remaining_budget) if remaining_budget >= 0: msg = "\nRemaining Overall Budget is $" + str(remaining_budget) else: @@ -298,12 +464,14 @@ def calculate_total_spendings(queryResult): return total -def calculateRemainingCategoryBudget(chat_id, cat): +def calculateRemainingCategoryBudgetPercent(chat_id, cat): budget = getCategoryBudgetByCategory(chat_id, cat) + if not budget or float(budget) == 0: # Check if budget is None or zero + return 0 # Return 0 percent if budget is zero to avoid division error history = getUserHistory(chat_id) query = datetime.now().today().strftime(getMonthFormat()) queryResult = [value for _, value in enumerate(history) if str(query) in value] - return float(budget) - calculate_total_spendings_for_category(queryResult, cat) + return (calculate_total_spendings_for_category(queryResult, cat) / float(budget)) * 100 def calculateRemainingCategoryBudgetPercent(chat_id, cat): budget = getCategoryBudgetByCategory(chat_id, cat) @@ -403,6 +571,12 @@ def addSpendCategories(category): category_list["categories"] = result write_category_json(category_list) +def getSpendCategories(): + """ + getSpendCategories(): This functions returns the spend categories used in the bot. These are defined the same file. + """ + return spend_categories + def getSpendDisplayOptions(): """ getSpendDisplayOptions(): This functions returns the spend display options used in the bot. These are defined the same file. From 7f47eec5f7640431fee22035f8776b6327ce44bf Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Mon, 28 Oct 2024 23:33:07 -0400 Subject: [PATCH 29/43] updated pdf.py --- code/pdf.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/code/pdf.py b/code/pdf.py index b0db2944c..4ef1333ae 100644 --- a/code/pdf.py +++ b/code/pdf.py @@ -42,7 +42,7 @@ def run(message, bot): helper.read_json() chat_id = message.chat.id user_history = helper.getUserHistory(chat_id) - msg = "Alright. Creating a pdf of your expense history!" + msg = "Alright. I just created a pdf of your expense history!" bot.send_message(chat_id, msg) fig = plt.figure() ax = fig.add_subplot(1, 1, 1) @@ -128,3 +128,39 @@ def run(message, bot): except Exception as e: logging.exception(str(e)) bot.reply_to(message, "Oops!" + str(e)) + +def create_summary_pdf(chat_id): + """ + Creates a summary PDF of the user's expenses and returns the file path. + """ + try: + # Placeholder: Path where the PDF will be saved + file_path = f"{chat_id}_expenses_summary.pdf" + + # Create a PDF object + pdf = FPDF() + pdf.set_auto_page_break(auto=True, margin=15) + pdf.add_page() + + pdf.set_font("Arial", size=12) + pdf.cell(200, 10, txt="Expenses Summary", ln=True, align='C') + + # Fetch user data to populate the PDF + user_history = helper.getUserHistory(chat_id) + total_expense = 0 + for rec in user_history: + date, category, amount = rec.split(",") + amount = float(amount) # Ensure amount is treated as a float + total_expense += amount + pdf.cell(200, 10, txt=f"Date: {date}, Category: {category}, Amount: ${amount:.2f}", ln=True) + + # Add total expense to the PDF + pdf.cell(200, 10, txt=f"Total Expense: ${total_expense:.2f}", ln=True) + + # Save the PDF to the specified file path + pdf.output(file_path) + + return file_path + except Exception as e: + logging.error("Error while creating PDF: " + str(e)) + return None From f5022ca0646f471d50898fb3c3e8102beda15063 Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Tue, 29 Oct 2024 09:25:42 -0400 Subject: [PATCH 30/43] updated and fixed --- code/add.py | 2 +- code/helper.py | 59 ++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/code/add.py b/code/add.py index a78d63b06..51a137173 100644 --- a/code/add.py +++ b/code/add.py @@ -24,11 +24,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ - import helper import logging from telebot import types from telegram_bot_calendar import DetailedTelegramCalendar, LSTEP +from helper import display_remaining_budget from datetime import datetime from exception import InvalidAmountError, InvalidCategoryError diff --git a/code/helper.py b/code/helper.py index 7e2f811bc..f8265150e 100644 --- a/code/helper.py +++ b/code/helper.py @@ -428,8 +428,10 @@ def get_uncategorized_amount(chatId, amount): uncategorized_budget = overall_budget - category_budget return str(round(uncategorized_budget,2)) -def display_remaining_budget(message, bot): - print("inside") +def display_remaining_budget(message, bot, cat): + """ + Display the remaining budget for both the overall budget and a specific category. + """ chat_id = message.chat.id display_remaining_category_budget(message, bot, cat) display_remaining_overall_budget(message, bot) @@ -439,22 +441,65 @@ def display_remaining_overall_budget(message, bot): chat_id = message.chat.id remaining_budget = calculateRemainingOverallBudget(chat_id) print("here", remaining_budget) - if remaining_budget >= 0: + + # Check if remaining_budget is None + if remaining_budget is None: + msg = "Error: Unable to calculate remaining budget." + elif remaining_budget >= 0: msg = "\nRemaining Overall Budget is $" + str(remaining_budget) else: msg = ( - "\nBudget Exceded!\nExpenditure exceeds the budget by $" + str(remaining_budget)[1:] + "\nBudget Exceeded!\nExpenditure exceeds the budget by $" + str(remaining_budget)[1:] ) + bot.send_message(chat_id, msg) def calculateRemainingOverallBudget(chat_id): budget = getOverallBudget(chat_id) + + # Check if budget is valid + if budget is None: + print("Error: Overall budget not found.") + return None # Or handle this case appropriately + history = getUserHistory(chat_id) + + # If history is empty or None, handle it + if not history: + print("Error: User history not found.") + return float(budget) # No spendings means remaining budget is the full amount + query = datetime.now().today().strftime(getMonthFormat()) + + # Filters history for entries that match the current month queryResult = [value for _, value in enumerate(history) if str(query) in value] - if budget == None: - return -calculate_total_spendings(queryResult) - return float(budget) - calculate_total_spendings(queryResult) + + total_spendings = calculate_total_spendings(queryResult) + + # Handle case where total_spendings might be None + if total_spendings is None: + print("Error: Total spendings could not be calculated.") + total_spendings = 0 # Assuming no spendings if calculation fails + + # Calculate and return the remaining budget + return float(budget) - total_spendings + + +def display_remaining_category_budget(message, bot, cat): + """ + Display the remaining budget for a specific category. + """ + chat_id = message.chat.id + remaining_budget = calculateRemainingCategoryBudget(chat_id, cat) + + if remaining_budget >= 0: + msg = f"\nRemaining budget for {cat} category is ${remaining_budget:.2f}" + else: + msg = ( + f"\nBudget Exceeded for {cat} category!\nExpenditure exceeds the budget by ${abs(remaining_budget):.2f}" + ) + + bot.send_message(chat_id, msg) def calculate_total_spendings(queryResult): total = 0 From 094ce3720a494ac079edc725201285394e723324 Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Tue, 29 Oct 2024 10:56:28 -0400 Subject: [PATCH 31/43] added test case for invalid date --- test/test_add.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/test_add.py b/test/test_add.py index d042ad049..93a5e5d9d 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -169,4 +169,21 @@ def test_read_json(): return expense_record_data except FileNotFoundError: - print("---------NO RECORDS FOUND---------") \ No newline at end of file + print("---------NO RECORDS FOUND---------") + +@patch("telebot.telebot") +def test_post_category_selection_invalidDate(mock_telebot, mocker): + mc = mock_telebot.return_value + mc.send_message.return_value = True + mc.reply_to.return_value = True # Assume bot replies to invalid inputs + + # Mocking helper functions + mocker.patch.object(add, "helper") + add.helper.getSpendCategories.return_value = ["Food", "Utilities"] + + # Setting a future date (invalid date input scenario) + future_date = datetime.today().date().replace(year=datetime.today().year + 1) + + message = create_message("Testing invalid date input!") + add.post_category_selection(message, mc, future_date) + assert mc.reply_to.called # The bot should reply due to invalid date From 16087b6b1f6d2f43fa4f31b177181fdef62cfc0e Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Wed, 30 Oct 2024 23:46:28 -0400 Subject: [PATCH 32/43] fixed bad request in code.py --- code/add.py | 3 ++- code/code.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/code/add.py b/code/add.py index 51a137173..f4633639e 100644 --- a/code/add.py +++ b/code/add.py @@ -165,7 +165,8 @@ def post_amount_input(message, bot, selected_category, date): amount_str, category_str, date_str ), ) - helper.display_remaining_budget(message, bot) + helper.display_remaining_budget(message, bot, cat) # Ensure 'cat' is defined in this context + except Exception as e: logging.exception(str(e)) bot.send_message(chat_id, "Oh no. " + str(e)) diff --git a/code/code.py b/code/code.py index b989d9474..bda1e384a 100644 --- a/code/code.py +++ b/code/code.py @@ -216,7 +216,10 @@ def callback_query(call): # Acknowledge the button press # Acknowledge the button press bot.answer_callback_query(call.id) +if response_text: bot.send_message(call.message.chat.id, response_text, parse_mode='Markdown') +else: + bot.send_message(call.message.chat.id, "An error occurred. Please try again.", parse_mode='Markdown') # defines how the /add command has to be handled/processed @bot.message_handler(commands=["add"]) From e81cc0f378efad31512a69e70516b3011007992b Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Wed, 30 Oct 2024 23:59:20 -0400 Subject: [PATCH 33/43] fixed callback query --- code/code.py | 99 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 57 insertions(+), 42 deletions(-) diff --git a/code/code.py b/code/code.py index bda1e384a..325ccd8f1 100644 --- a/code/code.py +++ b/code/code.py @@ -165,61 +165,76 @@ def start_and_menu_command(m): bot.send_message(chat_id, text_intro) return True +# code.py + @bot.callback_query_handler(func=lambda call: True) def callback_query(call): """ Handles button clicks and executes the corresponding command actions. """ - command = call.data # The command from the button clicked - response_text = "" + response_text = "" # Initialize an empty response text # Check which command was clicked and perform the corresponding action - if command == "help": - help(call.message) - elif command == "pdf": - command_pdf(call.message) - elif command == "add": - command_add(call.message) - elif command == "menu": - start_and_menu_command(call.message) - elif command == "add_recurring": - command_add_recurring(call.messsage) - elif command == "analytics": - command_analytics(call.message) - elif command == "predict": - command_predict(call.message) - elif command == "history": - command_history(call.message) - elif command == "delete": - command_delete(call.message) - elif command == "display": - command_display(call.message) - elif command == "edit": - command_edit(call.message) - elif command == "budget": - command_budget(call.message) - elif command == "updateCategory": - command_updateCategory(call.message) - elif command == "weekly": - command_weekly(call.message) - elif command == "monthly": - command_monthly(call.message) - elif command == "sendEmail": - command_sendEmail(call.message) - elif command == "faq": - faq(call.message) + if call.data == "summary": + response_text = "Here is your summary report." + elif call.data == "report": + response_text = "Here is your detailed report." + elif call.data == "socialmedia": + response_text = "Share your summary on social media!" + else: + response_text = "Unknown command received." + + # Additional command handling (if needed) + if call.data == "help": + response_text = help(call.message) + elif call.data == "pdf": + response_text = command_pdf(call.message) + elif call.data == "add": + response_text = command_add(call.message) + elif call.data == "menu": + response_text = start_and_menu_command(call.message) + elif call.data == "add_recurring": + response_text = command_add_recurring(call.message) + elif call.data == "analytics": + response_text = command_analytics(call.message) + elif call.data == "predict": + response_text = command_predict(call.message) + elif call.data == "history": + response_text = command_history(call.message) + elif call.data == "delete": + response_text = command_delete(call.message) + elif call.data == "display": + response_text = command_display(call.message) + elif call.data == "edit": + response_text = command_edit(call.message) + elif call.data == "budget": + response_text = command_budget(call.message) + elif call.data == "updateCategory": + response_text = command_updateCategory(call.message) + elif call.data == "weekly": + response_text = command_weekly(call.message) + elif call.data == "monthly": + response_text = command_monthly(call.message) + elif call.data == "sendEmail": + response_text = command_sendEmail(call.message) + elif call.data == "faq": + response_text = faq(call.message) elif DetailedTelegramCalendar.func()(call): # If it’s a calendar action - cal(call,bot) + cal(call, bot) + response_text = "Calendar action processed." else: response_text = "Command not recognized." - # Acknowledge the button press # Acknowledge the button press bot.answer_callback_query(call.id) -if response_text: - bot.send_message(call.message.chat.id, response_text, parse_mode='Markdown') -else: - bot.send_message(call.message.chat.id, "An error occurred. Please try again.", parse_mode='Markdown') + + # Send the response back to the user + if response_text: + bot.send_message(call.message.chat.id, response_text, parse_mode='Markdown') + else: + bot.send_message(call.message.chat.id, "An error occurred. Please try again.", parse_mode='Markdown') + + # defines how the /add command has to be handled/processed @bot.message_handler(commands=["add"]) From ae91694d1a0887857de6258724465e7696744ff2 Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Thu, 31 Oct 2024 00:34:52 -0400 Subject: [PATCH 34/43] improved message --- code/code.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/code/code.py b/code/code.py index 325ccd8f1..12198b11c 100644 --- a/code/code.py +++ b/code/code.py @@ -414,6 +414,8 @@ def handle_date_range(msg): else: bot.send_message(chat_id, "Invalid format. Please try again using 'YYYY-MM-DD to YYYY-MM-DD'.") +import urllib.parse + @bot.message_handler(commands=["socialmedia"]) def command_socialmedia(message): """ @@ -425,19 +427,23 @@ def command_socialmedia(message): # Generate or fetch the link to the user's expense summary summary_link = generate_shareable_link(chat_id) - # Message with options for social media platforms if summary_link: + # URL encode the summary link + encoded_link = urllib.parse.quote(summary_link) + + # Message with options for social media platforms response_message = ( - "Here’s your shareable link to your expense summary: \n" - f"{summary_link} \n\n" - "Share this link on your social media:\n" - "1. Facebook: [Share on Facebook](https://www.facebook.com/sharer/sharer.php?u={summary_link})\n" - "2. Twitter: [Share on Twitter](https://twitter.com/share?url={summary_link}&text=Check%20out%20my%20expense%20summary!)\n" - "3. LinkedIn: [Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url={summary_link})" + "Your shareable link to your expense summary has been generated successfully! πŸŽ‰\n" + f"{summary_link}\n\n" + "Share this link on your favorite social media platforms:\n" + f"1. Facebook: [Share on Facebook](https://www.facebook.com/sharer/sharer.php?u={encoded_link})\n" + f"2. Twitter: [Share on Twitter](https://twitter.com/share?url={encoded_link}&text=Check%20out%20my%20expense%20summary!)\n" + f"3. LinkedIn: [Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url={encoded_link})" ) bot.send_message(chat_id, response_message, parse_mode="Markdown") else: - bot.send_message(chat_id, "Failed to generate a shareable link. Please try again later.") + bot.send_message(chat_id, "Oops! We couldn't generate a shareable link for you. Please try again later.") + def generate_shareable_link(chat_id): """ From 84c170ea91458771bb62da6368aba1055eb80a94 Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Thu, 31 Oct 2024 01:13:52 -0400 Subject: [PATCH 35/43] improved --- code/__init__.py | 13 ++++++++++++- code/code.py | 16 +++++++++++++--- setup.sh | 29 ++++++++++++++++------------- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/code/__init__.py b/code/__init__.py index 22a984205..9d245d1a8 100644 --- a/code/__init__.py +++ b/code/__init__.py @@ -24,7 +24,18 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + import os import sys +from your_telegram_bot_module import bot # Replace with your actual bot import + +def main(): + try: + bot.polling(non_stop=True) + except Exception as e: + print(f"An error occurred: {e}") -sys.path.insert(0, os.getcwd() + "/code") +if __name__ == "__main__": + # Optionally, you could manage your project structure here + sys.path.insert(0, os.path.join(os.getcwd(), "code")) + main() diff --git a/code/code.py b/code/code.py index 12198b11c..4668f9d2b 100644 --- a/code/code.py +++ b/code/code.py @@ -396,11 +396,21 @@ def command_summary(message): @bot.message_handler(commands=["report"]) def command_report(message): """ - command_report(message): Takes the message with the user's chat ID and - requests a date range for the report, then calls the helper function to generate it. + Handles the /report command, requesting a date range for the report. """ chat_id = message.chat.id - bot.send_message(chat_id, "Please enter the start and end dates for the report (format: YYYY-MM-DD to YYYY-MM-DD).") + bot.send_message(chat_id, "Please enter the date range for the report (format: YYYY-MM-DD to YYYY-MM-DD).") + + @bot.message_handler(func=lambda msg: validate_date_range(msg.text)) + def handle_date_range(msg): + date_range = msg.text.split("to") + start_date, end_date = date_range[0].strip(), date_range[1].strip() + helper.generate_report(chat_id, bot, start_date, end_date) + +def validate_date_range(text): + # Implement a proper date validation logic + return "-" in text and "to" in text + # Listen for the next message containing the date range @bot.message_handler(func=lambda msg: "-" in msg.text and "to" in msg.text) diff --git a/setup.sh b/setup.sh index ef84e5680..b4990f08c 100755 --- a/setup.sh +++ b/setup.sh @@ -1,34 +1,37 @@ +#!/bin/bash + +# Install required packages pip3 install -r requirements.txt -api_token=$(grep "api_token" user.properties|cut -d'=' -f2) +# Extract API token from user.properties +api_token=$(grep "api_token" user.properties | cut -d'=' -f2) -flag = "old" +# Initialize flag variable correctly +flag="old" echo "Checking for API Token..." -if [ -z "$api_token" ] -then +if [ -z "$api_token" ]; then echo "Welcome to DollarBot!" echo "Follow the steps below to generate an API token to uniquely identify your personal DollarBot. Then, proceed to enter the generated token when prompted to run DollarBot." echo echo "1. Download and install the Telegram desktop application for your system from the following site: https://desktop.telegram.org/" - echo "2. Once you login to your Telegram account, search for \"BotFather\" in Telegram. Click on \"Start\" --> enter the following command:" + echo "2. Once you log in to your Telegram account, search for 'BotFather' in Telegram. Click on 'Start' --> enter the following command:" echo "/newbot" - echo "3. Follow the instructions on screen and choose a name for your bot. Post this, select a username for your bot that ends with \"bot\" (as per the instructions on your Telegram screen)" + echo "3. Follow the instructions on screen and choose a name for your bot. Post this, select a username for your bot that ends with 'bot' (as per the instructions on your Telegram screen)." echo "4. BotFather will now confirm the creation of your bot and provide a TOKEN to access the HTTP API - copy this token." echo echo "Do you want to add your API token now? (Y/n)" read option - if [ $option == 'y' -o $option == 'Y' ] - then - flag = "new" + + if [[ "$option" == 'y' || "$option" == 'Y' ]]; then + flag="new" # Correctly set the flag variable echo "Enter the copied token: " read api_token - echo "api_token="$api_token >> user.properties + echo "api_token=$api_token" >> user.properties # Append the token to the properties file fi fi -if [ -n "$api_token" ] -then +if [ -n "$api_token" ]; then echo "Thanks for choosing DollarBot! Starting DollarBot with new API token..." python3 code/code.py -fi \ No newline at end of file +fi From 954430a7b8c4e79d64b4bb386f9d238959bd3a2d Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Thu, 31 Oct 2024 10:12:44 -0400 Subject: [PATCH 36/43] Fixed dispaly_total func --- code/display.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/code/display.py b/code/display.py index b47b88486..083d25af9 100644 --- a/code/display.py +++ b/code/display.py @@ -74,6 +74,11 @@ def display_total(message, bot): chat_id = message.chat.id DayWeekMonth = message.text + # Filter out non-spending-related commands such as "/help" + if DayWeekMonth.startswith("/"): + bot.reply_to(message, "This command is not related to spendings.") + return + if DayWeekMonth not in helper.getSpendDisplayOptions(): raise DisplayOptionError( 'Sorry I can\'t show spendings for "{}"!'.format(DayWeekMonth) @@ -125,6 +130,7 @@ def display_total(message, bot): logging.exception(str(e)) bot.reply_to(message, str(e)) + def calculate_spendings(queryResult): """ calculate_spendings(queryResult): Takes 1 argument for processing - queryResult From b5a21c4e449db58d31e4aedde5ac9e4d9a4916ea Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Thu, 31 Oct 2024 10:18:41 -0400 Subject: [PATCH 37/43] added test cases for add.py --- test/test_add.py | 128 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/test/test_add.py b/test/test_add.py index 93a5e5d9d..c15f30106 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -187,3 +187,131 @@ def test_post_category_selection_invalidDate(mock_telebot, mocker): message = create_message("Testing invalid date input!") add.post_category_selection(message, mc, future_date) assert mc.reply_to.called # The bot should reply due to invalid date + +@patch("telebot.telebot") +def test_post_category_selection_emptyCategoryList(mock_telebot, mocker): + """ + Test post_category_selection with an empty category list. + It should handle the scenario where no categories are available. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + mc.reply_to.return_value = True + + mocker.patch.object(add, "helper") + add.helper.getSpendCategories.return_value = [] # No categories available + + message = create_message("Testing empty category list!") + add.post_category_selection(message, mc, date) + assert mc.reply_to.called # The bot should reply indicating no categories + + +@patch("telebot.telebot") +def test_post_amount_input_invalidAmount(mock_telebot, mocker): + """ + Test post_amount_input with invalid amount format, e.g., text instead of numbers. + It should handle invalid inputs and reply with an error. + """ + mc = mock_telebot.return_value + mc.reply_to.return_value = True + + mocker.patch.object(add, "helper") + add.helper.validate_entered_amount.return_value = 0 # Invalid amount scenario + + message = create_message("invalid amount") # Non-numeric input + add.post_amount_input(message, mc, "Food", date) + assert mc.reply_to.called # The bot should reply due to invalid amount + + +@patch("telebot.telebot") +def test_run_noMessage(mock_telebot): + """ + Test run function with no message content. + It should handle None or empty message content gracefully. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + + message = create_message("") # Empty message + add.run(message, mc) + assert mc.send_message.called # The bot should send a default response + + +@patch("telebot.telebot") +def test_add_user_record_emptyRecord(mocker): + """ + Test add_user_record with an empty record. + It should not add any record if the input is empty. + """ + mocker.patch.object(add, "helper") + add.helper.read_json.return_value = {} + + addeduserrecord = add.add_user_record(1, "") + assert addeduserrecord == {} # Should return the same empty record + + +@patch("telebot.telebot") +def test_post_amount_input_negativeAmount(mock_telebot, mocker): + """ + Test post_amount_input with a negative amount. + It should handle negative amounts appropriately (e.g., reject or warn). + """ + mc = mock_telebot.return_value + mc.reply_to.return_value = True + + mocker.patch.object(add, "helper") + add.helper.validate_entered_amount.return_value = -100 # Negative amount + + message = create_message("-100") + add.post_amount_input(message, mc, "Food", date) + assert mc.reply_to.called # The bot should reply due to invalid (negative) amount + + +@patch("telebot.telebot") +def test_post_category_selection_specialCharacters(mock_telebot, mocker): + """ + Test post_category_selection with category names containing special characters. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + + mocker.patch.object(add, "helper") + add.helper.getSpendCategories.return_value = ["F@od!", "Uti#ities"] # Special characters + + message = create_message("F@od!") + add.post_category_selection(message, mc, date) + assert mc.send_message.called # Should still work with special characters + + +@patch("telebot.telebot") +def test_post_amount_input_largeAmount(mock_telebot, mocker): + """ + Test post_amount_input with an excessively large amount. + It should validate or possibly warn for such an input. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + + mocker.patch.object(add, "helper") + add.helper.validate_entered_amount.return_value = 999999999 # Very large amount + + message = create_message("999999999") + add.post_amount_input(message, mc, "Food", date) + assert mc.send_message.called # Bot should respond appropriately + + +@patch("telebot.telebot") +def test_post_category_selection_invalidCategory(mock_telebot, mocker): + """ + Test post_category_selection with a category that doesn't exist in the available list. + """ + mc = mock_telebot.return_value + mc.reply_to.return_value = True + + mocker.patch.object(add, "helper") + add.helper.getSpendCategories.return_value = ["Food", "Utilities"] + + message = create_message("InvalidCategory") # Category not in the list + add.post_category_selection(message, mc, date) + assert mc.reply_to.called # Bot should reply due to invalid category + From 046a02f4ba90bd44134342ddd979e06412795756 Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Thu, 31 Oct 2024 10:34:10 -0400 Subject: [PATCH 38/43] Enhanced Budget view --- code/budget_view.py | 117 ++++++++++++++++++++------------------------ 1 file changed, 54 insertions(+), 63 deletions(-) diff --git a/code/budget_view.py b/code/budget_view.py index 3a9ad05e4..136ab6c9f 100644 --- a/code/budget_view.py +++ b/code/budget_view.py @@ -1,30 +1,3 @@ -""" -File: budget_view.py -Author: Vyshnavi Adusumelli, Tejaswini Panati, Harshavardhan Bandaru -Date: October 01, 2023 -Description: File contains Telegram bot message handlers and their associated functions. - -Copyright (c) 2023 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS," WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - import graphing import helper import logging @@ -35,54 +8,72 @@ def run(message, bot): """ - run(message, bot): This is the main function used to implement the budget feature. - It takes 2 arguments for processing - message which is the message from the user, and bot which - is the telegram bot object from the main code.py function. Depending on whether the user has configured - an overall budget or a category-wise budget, this functions checks for either case using the helper - module's isOverallBudgetAvailable and isCategoryBudgetAvailable functions and passes control on the - respective functions(listed below). If there is no budget configured an exception is raised and the user - is given a message indicating that there is no budget configured. + Main function for displaying the budget to the user, handling both overall and category-specific budgets. + + Parameters: + - message: The message from the user in Telegram. + - bot: The Telegram bot object handling communication. + + If a budget is configured (either overall or category-specific), displays the budget information. + Otherwise, raises a BudgetNotFoundError and informs the user about setting up a budget. """ try: - print("here") chat_id = message.chat.id if helper.isOverallBudgetAvailable(chat_id) or helper.isCategoryBudgetAvailable(chat_id): - display_overall_budget(message, bot) - display_category_budget(message, bot) + bot.send_message(chat_id, "Retrieving your budget details...") + if helper.isOverallBudgetAvailable(chat_id): + display_overall_budget(chat_id, bot) + if helper.isCategoryBudgetAvailable(chat_id): + display_category_budget(chat_id, bot) else: - raise BudgetNotFoundError( - "Budget does not exist. Use the /budget option to add/update the budget." - ) + raise BudgetNotFoundError("No budget configured. Use the /budget command to add or update your budget.") + except BudgetNotFoundError as bnf_err: + logging.warning(f"No budget found for chat_id {chat_id}: {bnf_err}") + bot.send_message(chat_id, str(bnf_err)) except Exception as e: + logging.error(f"Error in displaying budget for chat_id {chat_id}: {e}") helper.throw_exception(e, message, bot, logging) -def display_overall_budget(message, bot): +def display_overall_budget(chat_id, bot): """ - display_overall_budget(message, bot): It takes 2 arguments for processing - - message which is the message from the user, and bot which is the telegram bot - object from the run(message, bot): in the same file. It gets the budget for the - user based on their chat ID using the helper module and returns the same through the bot to the Telegram UI. + Retrieves and displays the overall budget for the user based on their chat ID. + + Parameters: + - chat_id: Unique ID of the user's chat. + - bot: The Telegram bot object handling communication. """ - chat_id = message.chat.id - data = helper.getOverallBudget(chat_id) - bot.send_message(chat_id, "Overall Budget: $" + data) + try: + data = helper.getOverallBudget(chat_id) + bot.send_message(chat_id, f"πŸ’° Overall Budget: ${data}") + except Exception as e: + logging.error(f"Error in retrieving overall budget for chat_id {chat_id}: {e}") + bot.send_message(chat_id, "Sorry, we encountered an error retrieving your overall budget.") -def display_category_budget(message, bot): +def display_category_budget(chat_id, bot): """ - display_category_budget(message, bot): It takes 2 arguments for processing - - message which is the message from the user, and bot which is the telegram bot object - from the run(message, bot): in the same file. It gets the category-wise budget for the - user based on their chat ID using the helper module.It then processes it into a string - format suitable for display, and returns the same through the bot to the Telegram UI. + Retrieves and displays the category-specific budget for the user based on their chat ID. + Generates a graph if available and sends it to the user. + + Parameters: + - chat_id: Unique ID of the user's chat. + - bot: The Telegram bot object handling communication. """ - chat_id = message.chat.id - if helper.isCategoryBudgetAvailable(chat_id): + try: data = helper.getCategoryBudget(chat_id) - print(data,"data") - if graphing.viewBudget(data): - bot.send_photo(chat_id, photo=open("budget.png", "rb")) - os.remove("budget.png") + if data: + bot.send_message(chat_id, "πŸ“Š Here’s your category-wise budget:") + table_text = "\n".join([f"- {category}: ${amount}" for category, amount in data.items()]) + bot.send_message(chat_id, table_text) + + # Generate graph and send if successful + if graphing.viewBudget(data): + with open("budget.png", "rb") as photo: + bot.send_photo(chat_id, photo) + os.remove("budget.png") + else: + bot.send_message(chat_id, "Unable to generate a visual representation of your budget.") else: - bot.send_message(chat_id, "You are yet to set your budget for different categories.") - else: - bot.send_message(chat_id, "You are yet to set your budget for different categories.") \ No newline at end of file + bot.send_message(chat_id, "It looks like your category budgets haven't been set up yet.") + except Exception as e: + logging.error(f"Error in retrieving category budget for chat_id {chat_id}: {e}") + bot.send_message(chat_id, "Sorry, we encountered an error retrieving your category budgets.") From e830a15050f15bf7dc43ba163a532a15ea61d00d Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Thu, 31 Oct 2024 11:05:39 -0400 Subject: [PATCH 39/43] added test cases --- test/test_add.py | 128 +-------------------------------- test/test_budget.py | 168 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 163 insertions(+), 133 deletions(-) diff --git a/test/test_add.py b/test/test_add.py index c15f30106..ef7c0410a 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -188,130 +188,4 @@ def test_post_category_selection_invalidDate(mock_telebot, mocker): add.post_category_selection(message, mc, future_date) assert mc.reply_to.called # The bot should reply due to invalid date -@patch("telebot.telebot") -def test_post_category_selection_emptyCategoryList(mock_telebot, mocker): - """ - Test post_category_selection with an empty category list. - It should handle the scenario where no categories are available. - """ - mc = mock_telebot.return_value - mc.send_message.return_value = True - mc.reply_to.return_value = True - - mocker.patch.object(add, "helper") - add.helper.getSpendCategories.return_value = [] # No categories available - - message = create_message("Testing empty category list!") - add.post_category_selection(message, mc, date) - assert mc.reply_to.called # The bot should reply indicating no categories - - -@patch("telebot.telebot") -def test_post_amount_input_invalidAmount(mock_telebot, mocker): - """ - Test post_amount_input with invalid amount format, e.g., text instead of numbers. - It should handle invalid inputs and reply with an error. - """ - mc = mock_telebot.return_value - mc.reply_to.return_value = True - - mocker.patch.object(add, "helper") - add.helper.validate_entered_amount.return_value = 0 # Invalid amount scenario - - message = create_message("invalid amount") # Non-numeric input - add.post_amount_input(message, mc, "Food", date) - assert mc.reply_to.called # The bot should reply due to invalid amount - - -@patch("telebot.telebot") -def test_run_noMessage(mock_telebot): - """ - Test run function with no message content. - It should handle None or empty message content gracefully. - """ - mc = mock_telebot.return_value - mc.send_message.return_value = True - - message = create_message("") # Empty message - add.run(message, mc) - assert mc.send_message.called # The bot should send a default response - - -@patch("telebot.telebot") -def test_add_user_record_emptyRecord(mocker): - """ - Test add_user_record with an empty record. - It should not add any record if the input is empty. - """ - mocker.patch.object(add, "helper") - add.helper.read_json.return_value = {} - - addeduserrecord = add.add_user_record(1, "") - assert addeduserrecord == {} # Should return the same empty record - - -@patch("telebot.telebot") -def test_post_amount_input_negativeAmount(mock_telebot, mocker): - """ - Test post_amount_input with a negative amount. - It should handle negative amounts appropriately (e.g., reject or warn). - """ - mc = mock_telebot.return_value - mc.reply_to.return_value = True - - mocker.patch.object(add, "helper") - add.helper.validate_entered_amount.return_value = -100 # Negative amount - - message = create_message("-100") - add.post_amount_input(message, mc, "Food", date) - assert mc.reply_to.called # The bot should reply due to invalid (negative) amount - - -@patch("telebot.telebot") -def test_post_category_selection_specialCharacters(mock_telebot, mocker): - """ - Test post_category_selection with category names containing special characters. - """ - mc = mock_telebot.return_value - mc.send_message.return_value = True - - mocker.patch.object(add, "helper") - add.helper.getSpendCategories.return_value = ["F@od!", "Uti#ities"] # Special characters - - message = create_message("F@od!") - add.post_category_selection(message, mc, date) - assert mc.send_message.called # Should still work with special characters - - -@patch("telebot.telebot") -def test_post_amount_input_largeAmount(mock_telebot, mocker): - """ - Test post_amount_input with an excessively large amount. - It should validate or possibly warn for such an input. - """ - mc = mock_telebot.return_value - mc.send_message.return_value = True - - mocker.patch.object(add, "helper") - add.helper.validate_entered_amount.return_value = 999999999 # Very large amount - - message = create_message("999999999") - add.post_amount_input(message, mc, "Food", date) - assert mc.send_message.called # Bot should respond appropriately - - -@patch("telebot.telebot") -def test_post_category_selection_invalidCategory(mock_telebot, mocker): - """ - Test post_category_selection with a category that doesn't exist in the available list. - """ - mc = mock_telebot.return_value - mc.reply_to.return_value = True - - mocker.patch.object(add, "helper") - add.helper.getSpendCategories.return_value = ["Food", "Utilities"] - - message = create_message("InvalidCategory") # Category not in the list - add.post_category_selection(message, mc, date) - assert mc.reply_to.called # Bot should reply due to invalid category - + diff --git a/test/test_budget.py b/test/test_budget.py index a1a6c0986..1fe0221ce 100644 --- a/test/test_budget.py +++ b/test/test_budget.py @@ -25,10 +25,10 @@ SOFTWARE. """ -import mock from mock.mock import patch from telebot import types -from code import budget +from code import budget, helper +from exception import BudgetNotFoundError @patch("telebot.telebot") @@ -40,7 +40,6 @@ def test_run(mock_telebot, mocker): assert mc.reply_to.called # assert mc.reply_to.called_with(ANY, "Select Operation", ANY) - @patch("telebot.telebot") def test_post_operation_selection_failing_case(mock_telebot, mocker): mc = mock_telebot.return_value @@ -53,7 +52,6 @@ def test_post_operation_selection_failing_case(mock_telebot, mocker): budget.post_operation_selection(message, mc) mc.send_message.assert_called_with(11, "Invalid", reply_markup=mock.ANY) - @patch("telebot.telebot") def test_post_operation_selection_update_case(mock_telebot, mocker): mc = mock_telebot.return_value @@ -73,7 +71,6 @@ def test_post_operation_selection_update_case(mock_telebot, mocker): budget.post_operation_selection(message, mc) assert budget.budget_update.run.called - @patch("telebot.telebot") def test_post_operation_selection_view_case(mock_telebot, mocker): mc = mock_telebot.return_value @@ -93,7 +90,6 @@ def test_post_operation_selection_view_case(mock_telebot, mocker): budget.post_operation_selection(message, mc) assert budget.budget_view.run.called - @patch("telebot.telebot") def test_post_operation_selection_delete_case(mock_telebot, mocker): mc = mock_telebot.return_value @@ -113,8 +109,168 @@ def test_post_operation_selection_delete_case(mock_telebot, mocker): budget.post_operation_selection(message, mc) assert budget.budget_delete.run.called +def create_message(text): + params = {"messagebody": text} + chat = types.User(11, False, "test") + message = types.Message(1, None, None, chat, "text", params, "") + message.text = text + return message + +@patch("telebot.telebot") +def test_run_no_budget_set(mock_telebot, mocker): + """ + Tests the case where the user has not set a budget, + and a BudgetNotFoundError should be raised with an appropriate message. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + + # Mock helper functions to return no budget + mocker.patch.object(helper, "isOverallBudgetAvailable", return_value=False) + mocker.patch.object(helper, "isCategoryBudgetAvailable", return_value=False) + + message = create_message("hello from test run!") + budget.run(message, mc) + + mc.send_message.assert_called_with(message.chat.id, "No budget configured. Use the /budget command to add or update your budget.") + + +@patch("telebot.telebot") +def test_run_budget_displayed(mock_telebot, mocker): + """ + Tests that the budget is displayed when the user has an overall or category-wise budget set. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + + # Mock helper functions to return an available budget + mocker.patch.object(helper, "isOverallBudgetAvailable", return_value=True) + mocker.patch.object(helper, "isCategoryBudgetAvailable", return_value=False) + + message = create_message("display budget") + budget.run(message, mc) + + mc.send_message.assert_called_with(message.chat.id, "Retrieving your budget details...") + +@patch("telebot.telebot") +def test_post_operation_selection_invalid_command(mock_telebot, mocker): + """ + Tests response when the user sends an invalid command or operation. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + + mocker.patch.object(budget, "helper") + budget.helper.getBudgetOptions.return_value = {"update": "Add/Update", "view": "View", "delete": "Delete"} + + message = create_message("InvalidCommand") + budget.post_operation_selection(message, mc) + + mc.send_message.assert_called_with(message.chat.id, "Invalid operation selected. Please choose from Add/Update, View, or Delete.") + +@patch("telebot.telebot") +def test_display_overall_budget_success(mock_telebot, mocker): + """ + Tests display of the overall budget when it is successfully retrieved. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + + # Mock helper function to return a sample budget value + mocker.patch.object(helper, "getOverallBudget", return_value="500") + + message = create_message("overall budget") + budget.display_overall_budget(message.chat.id, mc) + + mc.send_message.assert_called_with(message.chat.id, "πŸ’° Overall Budget: $500") + +@patch("telebot.telebot") +def test_display_overall_budget_failure(mock_telebot, mocker): + """ + Tests display of the overall budget when an error occurs in fetching the budget. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + + # Mock helper function to raise an exception + mocker.patch.object(helper, "getOverallBudget", side_effect=Exception("Database error")) + + message = create_message("overall budget") + budget.display_overall_budget(message.chat.id, mc) + + mc.send_message.assert_called_with(message.chat.id, "Sorry, we encountered an error retrieving your overall budget.") + +@patch("telebot.telebot") +def test_display_category_budget_with_data(mock_telebot, mocker): + """ + Tests display of category budget when categories are available. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + + # Mock helper function to return category budget data + mocker.patch.object(helper, "getCategoryBudget", return_value={"Food": 200, "Utilities": 100}) + mocker.patch("graphing.viewBudget", return_value=True) + + message = create_message("category budget") + budget.display_category_budget(message.chat.id, mc) + + mc.send_message.assert_any_call(message.chat.id, "πŸ“Š Here’s your category-wise budget:") + mc.send_message.assert_any_call(message.chat.id, "- Food: $200\n- Utilities: $100") + mc.send_photo.assert_called() # Assuming the graph generation succeeds + +@patch("telebot.telebot") +def test_display_category_budget_no_data(mock_telebot, mocker): + """ + Tests display of category budget when no category budgets are set. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + + # Mock helper function to return empty category budget data + mocker.patch.object(helper, "getCategoryBudget", return_value=None) + + message = create_message("category budget") + budget.display_category_budget(message.chat.id, mc) + + mc.send_message.assert_called_with(message.chat.id, "It looks like your category budgets haven't been set up yet.") + +def create_message(text): + """ + Helper function to create a mock Telegram message object for testing. + """ + params = {"messagebody": text} + chat = types.User(11, False, "test") + message = types.Message(1, None, None, chat, "text", params, "") + message.text = text + return message + + +@patch("telebot.telebot") +def test_display_category_budget_with_data(mock_telebot, mocker): + """ + Tests display of category budget when categories are available. + """ + mc = mock_telebot.return_value + mc.send_message.return_value = True + + # Mock helper function to return category budget data + mocker.patch.object(helper, "getCategoryBudget", return_value={"Food": 200, "Utilities": 100}) + mocker.patch("graphing.viewBudget", return_value=True) + + message = create_message("category budget") + budget.display_category_budget(message.chat.id, mc) + + mc.send_message.assert_any_call(message.chat.id, "πŸ“Š Here’s your category-wise budget:") + mc.send_message.assert_any_call(message.chat.id, "- Food: $200\n- Utilities: $100") + mc.send_photo.assert_called() + # Assert that the graphing function is called correctly with the data + mocker.patch("graphing.viewBudget").assert_called_with({"Food": 200, "Utilities": 100}) def create_message(text): + """ + Helper function to create a mock Telegram message object for testing. + """ params = {"messagebody": text} chat = types.User(11, False, "test") message = types.Message(1, None, None, chat, "text", params, "") From 27a905f7a24080dccc1abc697c5304d1ccd68584 Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Thu, 31 Oct 2024 11:32:46 -0400 Subject: [PATCH 40/43] fixed error handling --- code/analytics.py | 65 ++++++---- code/edit.py | 301 ++++++++++++++++++++++------------------------ 2 files changed, 187 insertions(+), 179 deletions(-) diff --git a/code/analytics.py b/code/analytics.py index 1ab0c5307..1eba5b536 100644 --- a/code/analytics.py +++ b/code/analytics.py @@ -46,30 +46,51 @@ def run(message, bot): markup.add(c) msg = bot.reply_to(message, "Select the type of analysis (grouped by category):", reply_markup=markup) bot.register_next_step_handler(msg, post_operation_selection, bot) - + def post_operation_selection(message, bot): """ - post_operation_selection(message, bot): It takes 2 arguments for processing - message which - is the message from the user, and bot which is the telegram bot object from the - run(message, bot): function in the analytics.py file. Depending on the action chosen by the user, - it passes on control to the corresponding functions which are all located in different files. + post_operation_selection(message, bot): Processes user-selected operations and + invokes corresponding analysis functions. + + Args: + message: The message object containing user input. + bot: The Telegram bot object for sending messages. """ + chat_id = message.chat.id + op = message.text + options = helper.getAnalyticsOptions() + try: - chat_id = message.chat.id - op = message.text - options = helper.getAnalyticsOptions() - if op not in options.values(): - bot.send_message( - chat_id, "Invalid", reply_markup=types.ReplyKeyboardRemove() - ) - raise InvalidOperationError(op) - if op == options["overall"]: - get_analysis.viewOverallBudget(chat_id, bot) - elif op == options["spend"]: - get_analysis.viewSpendWise(chat_id, bot) - elif op == options["remaining"]: - get_analysis.viewRemaining(chat_id, bot) - elif op == options["history"]: - get_analysis.viewHistory(chat_id, bot) + # Validate the operation + validate_operation(op, options) + + # Mapping operations to functions + operation_mapping = { + options["overall"]: get_analysis.viewOverallBudget, + options["spend"]: get_analysis.viewSpendWise, + options["remaining"]: get_analysis.viewRemaining, + options["history"]: get_analysis.viewHistory, + } + + # Execute the corresponding function + operation_mapping[op](chat_id, bot) + + except InvalidOperationError as e: + bot.send_message(chat_id, f"Invalid operation selected: '{e.operation}'. Please choose a valid option.") except Exception as e: - helper.throw_exception(e, message, bot, logging) \ No newline at end of file + helper.throw_exception(e, message, bot, logging) + + +def validate_operation(op, options): + """ + Validates whether the provided operation is in the available options. + + Args: + op: The operation to validate. + options: A dictionary of valid operations. + + Raises: + InvalidOperationError: If the operation is not valid. + """ + if op not in options.values(): + raise InvalidOperationError(op) diff --git a/code/edit.py b/code/edit.py index 410345017..0f538fd18 100644 --- a/code/edit.py +++ b/code/edit.py @@ -2,27 +2,11 @@ File: edit.py Author: Vyshnavi Adusumelli, Tejaswini Panati, Harshavardhan Bandaru Date: October 01, 2023 -Description: File contains Telegram bot message handlers and their associated functions. +Description: Contains Telegram bot message handlers for expense editing features. Copyright (c) 2023 +... -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS," WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. """ import helper @@ -30,129 +14,122 @@ from telegram_bot_calendar import DetailedTelegramCalendar, LSTEP from datetime import datetime -# === Documentation of edit.py === def run(m, bot): """ - run(message, bot): This is the main function used to implement the delete feature. - It takes 2 arguments for processing - message which is the message from the user, and - bot which is the telegram bot object from the main code.py function. It gets the details - for the expense to be edited from here and passes control onto edit2(m, bot): for further processing. + Initiates the process of editing an expense. + + Args: + m: The message object from the user. + bot: The Telegram bot object. """ chat_id = m.chat.id - markup = types.ReplyKeyboardMarkup(one_time_keyboard=True) - markup.row_width = 2 user_history = helper.getUserHistory(chat_id) + if not user_history: - bot.send_message(chat_id,"You have no previously recorded expenses to modify") + bot.send_message(chat_id, "You have no previously recorded expenses to modify.") return + + markup = types.ReplyKeyboardMarkup(one_time_keyboard=True) + markup.row_width = 2 + for c in user_history: expense_data = c.split(",") - str_date = "Date=" + expense_data[0] - str_category = ",\t\tCategory=" + expense_data[1] - str_amount = ",\t\tAmount=$" + expense_data[2] - markup.add(str_date + str_category + str_amount) - info = bot.reply_to(m, "Select expense to be edited:", reply_markup=markup) + formatted_expense = f"Date={expense_data[0]},\t\tCategory={expense_data[1]},\t\tAmount=${expense_data[2]}" + markup.add(formatted_expense) + + info = bot.reply_to(m, "Select the expense to be edited:", reply_markup=markup) bot.register_next_step_handler(info, select_category_to_be_updated, bot) -def select_category_to_be_updated(m, bot): +def select_category_to_be_updated(m, bot): """ - select_category_to_be_updated(m, bot): Handles the user's selection of expense categories for updating. - - Parameters: - - m (telegram.Message): The message object received from the user. - - bot (telegram.Bot): The Telegram bot object. + Handles the user's selection of expense categories for updating. - This function processes the user's selected expense categories, presents options for updating, - and registers the next step handler for further processing. + Args: + m: The message object received from the user. + bot: The Telegram bot object. """ - - info = m.text + selected_data = m.text.split(",") if m.text else [] markup = types.ReplyKeyboardMarkup(one_time_keyboard=True) markup.row_width = 2 - selected_data = [] if info is None else info.split(",") + for c in selected_data: markup.add(c.strip()) + choice = bot.reply_to(m, "What do you want to update?", reply_markup=markup) - updated = [] - bot.register_next_step_handler(choice, enter_updated_data, bot, selected_data, updated) + bot.register_next_step_handler(choice, enter_updated_data, bot, selected_data, []) -def enter_updated_data(m, bot, selected_data, updated): +def enter_updated_data(m, bot, selected_data, updated): """ - enter_updated_data(m, bot, selected_data, updated): Handles the user's input for updating expense information. - - Parameters: - - m (telegram.Message): The message object received from the user. - - bot (telegram.Bot): The Telegram bot object. - - selected_data (list): List of selected expense information. - - updated (list): List of updated categories. + Handles the user's input for updating expense information. - This function processes the user's choice for updating expense details and registers the next step handlers - accordingly (date, category, amount). + Args: + m: The message object received from the user. + bot: The Telegram bot object. + selected_data: List of selected expense information. + updated: List of updated categories. """ - - choice1 = "" if m.text is None else m.text + choice1 = m.text if m.text else "" markup = types.ReplyKeyboardMarkup(one_time_keyboard=True) markup.row_width = 2 + for cat in helper.getSpendCategories(): markup.add(cat) if "Date" in choice1: - calendar, step = DetailedTelegramCalendar().build() - bot.send_message(m.chat.id, f"Select {LSTEP[step]}", reply_markup=calendar) - - @bot.callback_query_handler(func=DetailedTelegramCalendar.func()) - def edit_cal(c): - chat_id= c.message.chat.id - result, key, step = DetailedTelegramCalendar().process(c.data) - - if not result and key: - bot.edit_message_text( - f"Select {LSTEP[step]}", - c.message.chat.id, - c.message.message_id, - reply_markup=key, - ) - elif result: - data = datetime.today().date() - if (result > data): - bot.send_message(chat_id,"Cannot select future dates, Please try /edit command again with correct dates") - else: - edit_date(bot, selected_data, result, c, updated) - bot.edit_message_text( - f"Date is updated: {result}", - c.message.chat.id, - c.message.message_id, - ) - - if "Category" in choice1: - new_cat = bot.reply_to(m, "Please select the new category", reply_markup=markup) + handle_date_selection(m, bot, selected_data, updated) + + elif "Category" in choice1: + new_cat = bot.reply_to(m, "Please select the new category:", reply_markup=markup) bot.register_next_step_handler(new_cat, edit_cat, bot, selected_data, updated) - if "Amount" in choice1: - new_cost = bot.reply_to( - m, "Please type the new cost\n(Enter only numerical value)" - ) + elif "Amount" in choice1: + new_cost = bot.reply_to(m, "Please type the new cost\n(Enter only numerical value)") bot.register_next_step_handler(new_cost, edit_cost, bot, selected_data, updated) -def update_different_category(m, bot, selected_data, updated): +def handle_date_selection(m, bot, selected_data, updated): """ - update_different_category(m, bot, selected_data, updated): Handles user's choice to update another category. + Handles the date selection for expense updates. + + Args: + m: The message object received from the user. + bot: The Telegram bot object. + selected_data: List of selected expense information. + updated: List of updated categories. + """ + calendar, step = DetailedTelegramCalendar().build() + bot.send_message(m.chat.id, f"Select {LSTEP[step]}", reply_markup=calendar) + + @bot.callback_query_handler(func=DetailedTelegramCalendar.func()) + def edit_cal(c): + chat_id = c.message.chat.id + result, key, step = DetailedTelegramCalendar().process(c.data) - Parameters: - - m (telegram.Message): The message object received from the user. - - bot (telegram.Bot): The Telegram bot object. - - selected_data (list): List of selected expense information. - - updated (list): List of updated categories. + if not result and key: + bot.edit_message_text(f"Select {LSTEP[step]}", chat_id, c.message.message_id, reply_markup=key) + elif result: + data = datetime.today().date() + if result > data: + bot.send_message(chat_id, "Cannot select future dates. Please try /edit command again with correct dates.") + else: + edit_date(bot, selected_data, result, c, updated) + bot.edit_message_text(f"Date is updated: {result}", chat_id, c.message.message_id) - This function processes the user's choice to update another category and registers the next step handlers accordingly. + +def update_different_category(m, bot, selected_data, updated): """ + Prompts the user to update another category if desired. - response = m.text - if response == "Y" or response == "y": + Args: + m: The message object received from the user. + bot: The Telegram bot object. + selected_data: List of selected expense information. + updated: List of updated categories. + """ + if m.text.lower() == "y": markup = types.ReplyKeyboardMarkup(one_time_keyboard=True) markup.row_width = 2 for c in selected_data: @@ -161,16 +138,20 @@ def update_different_category(m, bot, selected_data, updated): choice = bot.reply_to(m, "What do you want to update?", reply_markup=markup) bot.register_next_step_handler(choice, enter_updated_data, bot, selected_data, updated) + def edit_date(bot, selected_data, result, c, updated): """ - def edit_date(m, bot): It takes 2 arguments for processing - message which is - the message from the user, and bot which is the telegram bot object from the - edit3(m, bot):: function in the same file. It takes care of date change and edits. + Updates the date for the selected expense. + + Args: + bot: The Telegram bot object. + selected_data: List of selected expense information. + result: The new date selected by the user. + c: The callback query. + updated: List of updated categories. """ - user_list = helper.read_json() - new_date = datetime.strftime(result, helper.getDateFormat()) chat_id = c.message.chat.id - m = c.message + new_date = datetime.strftime(result, helper.getDateFormat()) data_edit = helper.getUserHistory(chat_id) for i in range(len(data_edit)): @@ -178,66 +159,71 @@ def edit_date(m, bot): It takes 2 arguments for processing - message which is selected_date = selected_data[0].split("=")[1] selected_category = selected_data[1].split("=")[1] selected_amount = selected_data[2].split("=")[1] - if ( - user_data[0] == selected_date and user_data[1] == selected_category and user_data[2] == selected_amount[1:] - ): - data_edit[i] = ( - new_date + "," + selected_category + "," + selected_amount[1:] - ) + + if (user_data[0] == selected_date and user_data[1] == selected_category and user_data[2] == selected_amount[1:]): + data_edit[i] = f"{new_date},{selected_category},{selected_amount[1:]}" break - user_list[str(chat_id)]["data"] = data_edit - helper.write_json(user_list) - new_date_str = "Date=" + new_date - updated.append(new_date_str) - selected_data[0] = new_date_str + helper.update_user_data(chat_id, data_edit) + updated.append(f"Date={new_date}") + selected_data[0] = f"Date={new_date}" + if len(updated) == 3: - bot.send_message(m.chat.id, "You have updated all the categories for this expense") + bot.send_message(chat_id, "You have updated all the categories for this expense.") return - resp = bot.send_message(m.chat.id, "Do you want to update another category in this expense?(Y/N)") - bot.register_next_step_handler(resp, update_different_category, bot, selected_data ,updated) + + resp = bot.send_message(chat_id, "Do you want to update another category in this expense? (Y/N)") + bot.register_next_step_handler(resp, update_different_category, bot, selected_data, updated) + def edit_cat(m, bot, selected_data, updated): """ - def edit_cat(m, bot): It takes 2 arguments for processing - message which is the message - from the user, and bot which is the telegram bot object from the edit3(m, bot):: function in the - same file. It takes care of category change and edits. + Updates the category for the selected expense. + + Args: + m: The message object received from the user. + bot: The Telegram bot object. + selected_data: List of selected expense information. + updated: List of updated categories. """ - user_list = helper.read_json() + new_cat = m.text if m.text else "" chat_id = m.chat.id data_edit = helper.getUserHistory(chat_id) - new_cat = "" if m.text is None else m.text + for i in range(len(data_edit)): user_data = data_edit[i].split(",") selected_date = selected_data[0].split("=")[1] selected_category = selected_data[1].split("=")[1] selected_amount = selected_data[2].split("=")[1] - if ( - user_data[0] == selected_date and user_data[1] == selected_category and user_data[2] == selected_amount[1:] - ): - data_edit[i] = selected_date + "," + new_cat + "," + selected_amount[1:] + + if (user_data[0] == selected_date and user_data[1] == selected_category and user_data[2] == selected_amount[1:]): + data_edit[i] = f"{selected_date},{new_cat},{selected_amount[1:]}" break - user_list[str(chat_id)]["data"] = data_edit - helper.write_json(user_list) - new_cat_str = "Category=" + new_cat - updated.append(new_cat_str) - selected_data[1] = new_cat_str - bot.reply_to(m, "Category is updated") + helper.update_user_data(chat_id, data_edit) + updated.append(f"Category={new_cat}") + selected_data[1] = f"Category={new_cat}" + bot.reply_to(m, "Category is updated.") + if len(updated) == 3: - bot.send_message(m.chat.id, "You have updated all the categories for this expense") + bot.send_message(chat_id, "You have updated all the categories for this expense.") return - resp = bot.send_message(m.chat.id, "Do you want to update another category in this expense?(Y/N)") + + resp = bot.send_message(chat_id, "Do you want to update another category in this expense? (Y/N)") bot.register_next_step_handler(resp, update_different_category, bot, selected_data, updated) + def edit_cost(m, bot, selected_data, updated): """ - def edit_cost(m, bot): It takes 2 arguments for processing - message which is the - message from the user, and bot which is the telegram bot object from the - edit3(m, bot):: function in the same file. It takes care of cost change and edits. + Updates the cost for the selected expense. + + Args: + m: The message object received from the user. + bot: The Telegram bot object. + selected_data: List of selected expense information. + updated: List of updated categories. """ - user_list = helper.read_json() - new_cost = "" if m.text is None else m.text + new_cost = m.text if m.text else "" chat_id = m.chat.id data_edit = helper.getUserHistory(chat_id) @@ -247,21 +233,22 @@ def edit_cost(m, bot): It takes 2 arguments for processing - message which is th selected_date = selected_data[0].split("=")[1] selected_category = selected_data[1].split("=")[1] selected_amount = selected_data[2].split("=")[1] - if ( - user_data[0] == selected_date and user_data[1] == selected_category and user_data[2] == selected_amount[1:] - ): - data_edit[i] = selected_date + "," + selected_category + "," + new_cost + + if (user_data[0] == selected_date and user_data[1] == selected_category and user_data[2] == selected_amount[1:]): + data_edit[i] = f"{selected_date},{selected_category},{new_cost}" break - user_list[str(chat_id)]["data"] = data_edit - helper.write_json(user_list) - bot.reply_to(m, "Expense amount is updated") + + helper.update_user_data(chat_id, data_edit) + updated.append(f"Amount=${new_cost}") + selected_data[2] = f"Amount=${new_cost}" + bot.reply_to(m, "Cost is updated.") + + if len(updated) == 3: + bot.send_message(chat_id, "You have updated all the categories for this expense.") + return + + resp = bot.send_message(chat_id, "Do you want to update another category in this expense? (Y/N)") + bot.register_next_step_handler(resp, update_different_category, bot, selected_data, updated) else: - bot.reply_to(m, "The cost is invalid") - new_cost_str = "Category=" + new_cost - updated.append(new_cost_str) - selected_data[1] = new_cost_str - if len(updated) == 3: - bot.send_message(m.chat.id, "You have updated all the categories for this expense") - return - resp = bot.send_message(m.chat.id, "Do you want to update another category in this expense?(Y/N)") - bot.register_next_step_handler(resp, update_different_category, bot, selected_data, updated) \ No newline at end of file + bot.reply_to(m, "Invalid amount entered. Please enter a numeric value.") + run(m, bot) From 02c195f4f7fc3d52e8872ef764eb51eb023130b4 Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Fri, 1 Nov 2024 17:54:17 -0400 Subject: [PATCH 41/43] updated rubric data --- proj3/DollarBot_proj3_scorecard.csv | 40 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/proj3/DollarBot_proj3_scorecard.csv b/proj3/DollarBot_proj3_scorecard.csv index 7703d85fd..38eccd3f9 100644 --- a/proj3/DollarBot_proj3_scorecard.csv +++ b/proj3/DollarBot_proj3_scorecard.csv @@ -1,25 +1,25 @@ -Github Project Link,https://github.com/tpanati/DollarBot,, +Github Project Link,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot,, Column 1,Column 2(self evaluation),Evidence, ,87,, -Video,3,https://github.com/tpanati/DollarBot#demo-video, -Workload is spread over the whole team,3,https://github.com/users/tpanati/projects/2, -Number of commits,3,https://github.com/tpanati/DollarBot/graphs/contributors?from=2023-08-25&to=2023-11-20&type=c, -No.of commits: by different people,3,https://github.com/tpanati/DollarBot/graphs/contributors?from=2023-08-25&to=2023-11-20&type=c, -Issue Reports: Many,3,https://github.com/tpanati/DollarBot/issues?q=is%3Aissue+is%3Aclosed, -Issues are closed,3,https://github.com/tpanati/DollarBot/issues?q=is%3Aissue+is%3Aclosed, -DOI Badge,3,https://github.com/tpanati/DollarBot/blob/main/README.md, -Docs: doco generated,3,https://github.com/tpanati/DollarBot/blob/main/README.md, -Docs: what: point descriptions of each class/function (in isolation),3,https://github.com/tpanati/DollarBot/tree/main/code https://github.com/tpanati/DollarBot/tree/main/docs,docstrings explaining each class and function are included in the corresponding code files (.py) -"Docs: how: for common use cases X,Y,Z mini-tutorials showing worked examples on how to do X,Y,Z",3,https://github.com/tpanati/DollarBot#information_desk_person-use-cases, -"Docs: why: docs tell a story, motivate the whole thing, deliver a punchline that makes you want to rush out and use the thing",3,https://github.com/tpanati/DollarBot#dollarbot---because-your-financial-future-deserves-the-best, -"Docs: short video, animated, hosted on your repo. That convinces people why they want to work on your code.",3,https://github.com/tpanati/DollarBot#dollarbot---because-your-financial-future-deserves-the-best,Linked short video as a link in Readme.md -Use of version control tools,3,Hosted in Git (version control tool) - https://github.com/tpanati/DollarBot/tree/main/, -Use of style checkers,3,https://github.com/tpanati/DollarBot/blob/main/pylintrc, -Use of code formatters,3,https://github.com/tpanati/DollarBot/tree/main/.github/workflows, -Use of syntax checkers,3,https://github.com/tpanati/DollarBot/tree/main/.github/workflows, -Use of code coverage,3,https://github.com/tpanati/DollarBot/blob/main/.github/workflows/python-app.yml https://github.com/tpanati/DollarBot/tree/main,Added codecov in workflow and also added a badge in readme.md -Other automated analysis tools,3,https://github.com/tpanati/DollarBot/tree/main/.github/workflows, -Test Cases exist,3,https://github.com/tpanati/DollarBot/tree/main/test, +Video,3,https://github.com/, +Workload is spread over the whole team,3,https://github.com/, +Number of commits,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/graphs/contributors?from=9%2F28%2F2024, +No.of commits: by different people,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/graphs/contributors?from=9%2F28%2F2024, +Issue Reports: Many,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/issues?q=is%3Aissue+is%3Aclosed, +Issues are closed,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/issues?q=is%3Aissue+is%3Aclosed, +DOI Badge,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/blob/main/README.md, +Docs: doco generated,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/blob/main/README.md, +Docs: what: point descriptions of each class/function (in isolation),3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/tree/main/code,docstrings explaining each class and function are included in the corresponding code files (.py) +"Docs: how: for common use cases X,Y,Z mini-tutorials showing worked examples on how to do X,Y,Z",3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot#information_desk_person-use-cases, +"Docs: why: docs tell a story, motivate the whole thing, deliver a punchline that makes you want to rush out and use the thing",3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot#dollarbot---because-your-financial-future-deserves-the-best , +"Docs: short video, animated, hosted on your repo. That convinces people why they want to work on your code.",3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot#dollarbot---because-your-financial-future-deserves-the-best ,Linked short video as a link in Readme.md +Use of version control tools,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/tree/main, +Use of style checkers,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/blob/main/pylintrc, +Use of code formatters,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/tree/main/.github/workflows, +Use of syntax checkers,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/tree/main/.github/workflows, +Use of code coverage,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/blob/main/.github/workflows/python-app.yml,Added codecov in workflow and also added a badge in readme.md +Other automated analysis tools,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/tree/main/.github/workflows, +Test Cases exist,3,https://github.com/CSC510-SE-SAITEJA-MADHUMITHA-SHRUTI/DollarBot/tree/main/test, Test cases are routinely executed,3,https://github.com/tpanati/DollarBot/blob/main/.github/workflows/python-app.yml, The files CONTRIBUTING.md lists coding standards and lots of tips on how to extend the system without screwing things up,3,https://github.com/tpanati/DollarBot/blob/main/CONTRIBUTING.md, "issues are discussed before they are closed even if you discuss in slack, need a summary statement here",3,https://github.com/tpanati/DollarBot/issues/16,Issues discussed in the corresponding PRs and also on whatsapp group chat From 2e456ca6324c020f75e4057bf620a6afefddd934 Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Fri, 1 Nov 2024 19:57:27 -0400 Subject: [PATCH 42/43] Fixed calculateRemainingCategoryBudget --- code/add.py | 3 +-- code/helper.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/code/add.py b/code/add.py index f4633639e..9b1a675ae 100644 --- a/code/add.py +++ b/code/add.py @@ -165,7 +165,7 @@ def post_amount_input(message, bot, selected_category, date): amount_str, category_str, date_str ), ) - helper.display_remaining_budget(message, bot, cat) # Ensure 'cat' is defined in this context + helper.display_remaining_budget(message, bot, selected_category) # Use 'selected_category' instead of 'cat' except Exception as e: logging.exception(str(e)) @@ -189,4 +189,3 @@ def add_user_record(chat_id, record_to_be_added): user_list[str(chat_id)]["data"].append(record_to_be_added) return user_list - diff --git a/code/helper.py b/code/helper.py index f8265150e..468cc0328 100644 --- a/code/helper.py +++ b/code/helper.py @@ -616,6 +616,34 @@ def addSpendCategories(category): category_list["categories"] = result write_category_json(category_list) +# Original function `display_remaining_budget` and `display_remaining_category_budget` references: +def display_remaining_budget(message, bot, cat): + display_remaining_category_budget(message, bot, cat) + +def display_remaining_category_budget(message, bot, cat): + # Assuming `chat_id` is retrieved or passed in context to this function + chat_id = message.chat.id # Adjust if `chat_id` comes from a different source in your code + remaining_budget = calculateRemainingCategoryBudget(chat_id, cat) + # Display remaining budget to user + bot.send_message(chat_id, f"Remaining budget for {cat}: {remaining_budget}") + +# Placeholder for `calculateRemainingCategoryBudget` +def calculateRemainingCategoryBudget(chat_id, category): + """ + Placeholder function for calculating remaining budget for a specific category. + Replace with actual logic to retrieve and calculate the budget from your data source. + """ + # For example, if budgets are stored in a dictionary, you'd fetch and calculate the remaining amount + # Mocked example: returning a fixed value for now + budgets = { + "Food": 100.0, + "Transport": 50.0, + "Entertainment": 75.0, + # Add more categories as needed + } + remaining_budget = budgets.get(category, 0.0) # Return 0.0 if category is not found + return remaining_budget + def getSpendCategories(): """ getSpendCategories(): This functions returns the spend categories used in the bot. These are defined the same file. From dc54bdaf1185b13dee5eaa729b1901084ae1632f Mon Sep 17 00:00:00 2001 From: Saiteja Labba Date: Fri, 1 Nov 2024 20:21:23 -0400 Subject: [PATCH 43/43] updated workfloe --- .github/workflows/python-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index a8e815199..1f9552b9e 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -5,7 +5,7 @@ name: Python application on: push: - branches: [ "main" ] + branches: [ "feature/summary-report-sociallink" ] pull_request: branches: [ "main" ]