diff --git a/bot/database_service/__init__.py b/bot/database_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/database_service/auth.py b/bot/database_service/auth.py new file mode 100644 index 0000000..927260e --- /dev/null +++ b/bot/database_service/auth.py @@ -0,0 +1,17 @@ +from firebase_admin import firestore +import firebase_admin +from firebase_admin import credentials +import os +import json + + +def get_db_client(): + """ + Get credentials for the Google Sheets API. + """ + firebase_json = json.loads(os.environ["FIREBASE_JSON"]) + + cred = credentials.Certificate(firebase_json) + firebase_admin.initialize_app(cred) + + return firestore.client() diff --git a/bot/database_service/firestore_service.py b/bot/database_service/firestore_service.py new file mode 100644 index 0000000..13a87af --- /dev/null +++ b/bot/database_service/firestore_service.py @@ -0,0 +1,44 @@ +from bot.database_service.auth import get_db_client + + +class FirestoreService: + """ + This class is responsible for managing the Firestore database. + """ + + def __init__(self): + self.db = get_db_client() + + # New user setup + def new_user_setup(self, telegram_id, sheet_id): + user_ref = self.db.collection("users").document(str(telegram_id)) + user_ref.set({"sheet_id": sheet_id}) + + # Check if user exists + def check_if_user_exists(self, telegram_id): + user_ref = self.db.collection("users").document(str(telegram_id)) + user_doc = user_ref.get() + return user_doc.exists + + # Get user sheet id + def get_user_sheet_id(self, telegram_id): + user_ref = self.db.collection("users").document(str(telegram_id)) + user_doc = user_ref.get() + if user_doc.exists: + return user_doc.get("sheet_id") + else: + return None + + # Get all user IDs + def get_all_user_id(self): + users_ref = self.db.collection("users") + user_ids = [int(user.id) for user in users_ref.stream()] + return user_ids + + # Get all sheet IDs + def get_all_sheet_id(self): + users_ref = self.db.collection("users") + sheet_ids = [] + for user in users_ref.stream(): + sheet_ids.append(user.get("sheet_id")) + return sheet_ids diff --git a/bot/sheet_service/__init__.py b/bot/sheet_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/sheet_service/auth.py b/bot/sheet_service/auth.py new file mode 100644 index 0000000..2135996 --- /dev/null +++ b/bot/sheet_service/auth.py @@ -0,0 +1,27 @@ +""" +auth.py + +This file contains a function that returns the credentials for the Google Sheets API. +The credentials are obtained from the GOOGLE_JSON environment variable, which is set in the .env file. +The get_credentials function uses the google.oauth2.service_account module to create a credentials +object from the service account info in the GOOGLE_JSON environment variable. +The credentials object is then returned to the caller. + +""" + +import os +import json +from google.oauth2 import service_account + + +def get_credentials(): + """ + Get credentials for the Google Sheets API. + """ + SCOPES = ["https://www.googleapis.com/auth/spreadsheets"] + google_json = os.getenv("GOOGLE_JSON") + google_service = json.loads(google_json) + creds = service_account.Credentials.from_service_account_info( + google_service, scopes=SCOPES + ) + return creds diff --git a/bot/sheet_service/exceptions.py b/bot/sheet_service/exceptions.py new file mode 100644 index 0000000..cc8583c --- /dev/null +++ b/bot/sheet_service/exceptions.py @@ -0,0 +1,58 @@ +from googleapiclient.errors import HttpError +from functools import wraps + +# Custom exception classes defined here + + +def google_sheets_exception_handler(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except HttpError as e: + if e.resp.status == 401: + raise AuthenticationError( + "Authentication failed: Check your credentials." + ) + elif e.resp.status == 404: + raise SpreadsheetNotFoundError( + "Spreadsheet not found: Check your spreadsheet ID." + ) + else: + raise APIRequestError( + f"API request failed with status {e.resp.status}: {e.error_details}" + ) + except Exception as e: + raise SheetsServiceError(f"An unexpected error occurred: {str(e)}") + + return wrapper + + +class SheetsServiceError(Exception): + """Base class for exceptions in this module.""" + + pass + + +class AuthenticationError(SheetsServiceError): + """Raised when there's an issue with authentication or credentials.""" + + pass + + +class SpreadsheetNotFoundError(SheetsServiceError): + """Raised when a specified spreadsheet cannot be found.""" + + pass + + +class APIRequestError(SheetsServiceError): + """Raised for errors related to making API requests to Google Sheets.""" + + pass + + +class InvalidEntryTypeError(SheetsServiceError): + """Raised when an invalid entry type is specified.""" + + pass diff --git a/bot/sheet_service/sheets_range.py b/bot/sheet_service/sheets_range.py new file mode 100644 index 0000000..298f40c --- /dev/null +++ b/bot/sheet_service/sheets_range.py @@ -0,0 +1,115 @@ +""" +sheets_range.py + +This file contains the ranges for the Google Sheets Services for consistency. +The ranges are used to access specific cells in the Google Sheets. + +""" + +from bot.google_sheet_service.utils import create_range, create_complex_range + +# Sheet names +DD_SHEET = "Dropdown" # DD for Dropdown +TR_SHEET = "Tracker" # TR for Tracker + +# Tracker columns +TR_QUICKADD_TP_PAY = "G" # Transport Payment Column +TR_QUICKADD_TP_TYPE = "H" # Transport Type Column +TR_QUICKADD_OT_PAY = "I" # Others Payment Column +TR_QUICKADD_OT_TYPE = "J" # Others Type Column + +TR_QUICKADD_ROW_START = 3 # Quick Add Row +TR_QUICKADD_ROW_END = 13 # Quick Add End Row + +TR_START_COL = "B" # Start Column +TR_END_COL = "E" # End Column +TR_ROW = 3 + +# Dropdown rows +DD_MAIN_CAT_ROW = 2 +DD_SUBCAT_START = 3 +DD_SUBCAT_END = 9 + +DD_MAIN_PAY_ROW = 12 +DD_SUBPAY_START = 13 +DD_SUBPAY_END = 19 + +DD_TRANSPORT_COL = "A" +DD_OTHERS_COL_START = "B" +DD_OTHERS_COL_END = "J" + +DD_INCOME_COL = "L" + +DD_PAYMENT_COL_START = "A" +DD_PAYMENT_COL_END = "J" + +# Column indexes +START_COL_IDX = 0 +END_COL_IDX = 11 + +# Months +OVERALL_RANGE = "!M13:O25" + +# Transport ranges +TRANSPORT_RANGE = create_range( + DD_SHEET, DD_TRANSPORT_COL, DD_SUBCAT_START, DD_TRANSPORT_COL, DD_SUBCAT_END +) + +# Others ranges +OTHERS_MAIN_RANGE = create_range( + DD_SHEET, DD_OTHERS_COL_START, DD_MAIN_CAT_ROW, DD_OTHERS_COL_END, DD_MAIN_CAT_ROW +) + +OTHERS_SUB_RANGE = create_complex_range( + DD_SHEET, + ord(DD_OTHERS_COL_START), + ord(DD_OTHERS_COL_END), + DD_MAIN_CAT_ROW, + DD_SUBCAT_END, +) + +# Payment ranges +PAYMENT_MAIN_RANGE = create_range( + DD_SHEET, DD_PAYMENT_COL_START, DD_MAIN_PAY_ROW, DD_PAYMENT_COL_END, DD_MAIN_PAY_ROW +) + +PAYMENT_SUB_RANGE = create_complex_range( + DD_SHEET, + ord(DD_PAYMENT_COL_START), + ord(DD_PAYMENT_COL_END), + DD_MAIN_PAY_ROW, + DD_SUBPAY_END, +) + +# Income range +INCOME_RANGE = create_range( + DD_SHEET, DD_INCOME_COL, DD_MAIN_CAT_ROW, DD_INCOME_COL, DD_SUBCAT_END +) + +# Tracker ranges +TRACKER_RANGE = create_range(TR_SHEET, TR_START_COL, TR_ROW, TR_END_COL, TR_ROW) + +# Quick add ranges +QUICK_ADD_RANGE = create_range( + TR_SHEET, + TR_QUICKADD_TP_PAY, + TR_QUICKADD_ROW_START, + TR_QUICKADD_OT_TYPE, + TR_QUICKADD_ROW_START, +) + +QUICK_OTHERS_RANGE = create_range( + TR_SHEET, + TR_QUICKADD_OT_PAY, + TR_QUICKADD_ROW_START, + TR_QUICKADD_OT_TYPE, + TR_QUICKADD_ROW_END, +) + +QUICK_TRANSPORT_RANGE = create_range( + TR_SHEET, + TR_QUICKADD_TP_PAY, + TR_QUICKADD_ROW_START, + TR_QUICKADD_TP_TYPE, + TR_QUICKADD_ROW_END, +) diff --git a/bot/sheet_service/sheets_services.py b/bot/sheet_service/sheets_services.py new file mode 100644 index 0000000..83621f4 --- /dev/null +++ b/bot/sheet_service/sheets_services.py @@ -0,0 +1,207 @@ +""" +sheet_api.py + + +""" + +# sheets_api.py +from googleapiclient.discovery import build +from bot.common import EntryType +from bot.google_sheet_service.auth import get_credentials +from bot.google_sheet_service.sheets_range import * + + +class GoogleSheetsClient: + _instance = None + + @classmethod + def get_instance(cls): + if cls._instance is None: + creds = get_credentials() + cls._instance = build("sheets", "v4", credentials=creds) + return cls._instance + + +class DropdownManager: + """ + This class is responsible for managing the Dropdown sheet in the google sheet. + """ + + def __init__(self): + self.sheets_api = GoogleSheetsClient.get_instance() + + # to be rename as get_header_values + def get_main_dropdown_value(self, spreadsheet_id, entry_type) -> list[str]: + """ + This method gets the header values for the CATEGORY/PAYMENT of the transaction. + """ + range = [] + if entry_type == EntryType.TRANSPORT: + # actually if entry_type is transport shouldnt be calling this + # but instead call get_sub_dropdown_value instead + # will do something to make this change after refactoring + range = TRANSPORT_RANGE + elif entry_type == EntryType.OTHERS: + range = OTHERS_MAIN_RANGE + else: + range = PAYMENT_MAIN_RANGE + + results = ( + self.sheets_api.spreadsheets() + .values() + .get(spreadsheetId=spreadsheet_id, range=range) + .execute() + ) + + values_results = results.get("values", []) + + # to remove this and probably move it to get_sub_dropdown_value + if entry_type == EntryType.TRANSPORT: + results_list = [] + for sublist in values_results: + for item in sublist: + results_list.append(item) + return results_list + + return values_results[0] + + # to be rename as get_sub_values + def get_sub_dropdown_value( + self, spreadsheet_id, header_value, entry_type + ) -> list[str]: + """ + This method gets the sub values for the CATEGORY/PAYMENT of the transaction. + """ + range = [] + # if entry_type is transport, should be calling this instead of get_main_dropdown_value + if entry_type == EntryType.OTHERS: + range = OTHERS_SUB_RANGE + else: + range = PAYMENT_SUB_RANGE + results = ( + self.sheets_api.spreadsheets() + .values() + .batchGet(spreadsheetId=spreadsheet_id, ranges=range) + .execute() + ) + + value_results = results.get("valueRanges", []) + + dropdown = [] + for value in value_results: + if value.get("values", []): + if header_value == value.get("values", [])[0][0]: + dropdown.append(value.get("values", [])) + pass + + result_list = [item for sublist in dropdown[0] for item in sublist] + return result_list + + +class TrackerManager: + """ + This class is responsible for managing the Tracker sheet in the google sheet. + """ + + def __init__(self): + self.sheets_api = GoogleSheetsClient.get_instance() + + +class EntryManager: + """ + This class is responsible for logging of transactions in the google sheet. + """ + + def __init__(self): + self.sheets_api = GoogleSheetsClient.get_instance() + + def create_entry(self, spreadsheet_id, month, row_tracker, row_data): + entry_type = row_data[0] + price = row_data[1].strip() + remarks = row_data[2].strip() + category = row_data[3].strip() + payment = row_data[4].strip() + + data = [price, remarks, category, payment] + sheet_column_start = "H" + sheet_column_end = "K" + if entry_type == EntryType.TRANSPORT: + remarks_list = [remark.strip() for remark in remarks.split(",")] + sheet_column_start = "C" + sheet_column_end = "G" + data = [price] + remarks_list + [category, payment] + + body = {"values": [data]} + range_name = ( + f"{month}!{sheet_column_start}{row_tracker}:{sheet_column_end}{row_tracker}" + ) + self.sheets_api.spreadsheets().values().update( + spreadsheetId=spreadsheet_id, + range=range_name, + valueInputOption="USER_ENTERED", + body=body, + ).execute() + + +class SheetManager: + """ + This class is responsible for retrieving/moving the google sheet. + """ + + def __init__(self): + self.sheets_api = GoogleSheetsClient.get_instance() + + def get_last_entered_row(self, spreadsheet_id, month): + """ + This method gets the last entered row for the month. + """ + result = ( + self.sheets_api.spreadsheets() + .values() + .get(spreadsheetId=spreadsheet_id, range=f"{month}!A:K") + .execute() + ) + values = result.get("values", []) + return len(values) + + # to be rename as update_prev_day_total + def update_prev_day(self, spreadsheet_id, month, first_row, last_row=0): + """ + This method update the total amount spend for the previous day + """ + if last_row == 0: + last_row = self.get_last_entered_row(spreadsheet_id, month) + body = {"values": [[f"=SUM(C{first_row}:H{last_row})"]]} + range_name = f"{month}!B{first_row}" + self.sheets_api.spreadsheets().values().update( + spreadsheetId=spreadsheet_id, + range=range_name, + valueInputOption="USER_ENTERED", + body=body, + ).execute() + + def create_date(self, spreadsheet_id, day, month, first_row): + """ + This method creates the date for the day. + """ + body = {"values": [[day]]} + range_name = f"{month}!A{first_row}" + self.sheets_api.spreadsheets().values().update( + spreadsheetId=spreadsheet_id, + range=range_name, + valueInputOption="USER_ENTERED", + body=body, + ).execute() + + def get_sheet_id_by_title(self, spreadsheet_id, title_to_find): + sheet_metadata = ( + self.sheets_api.spreadsheets().get(spreadsheetId=spreadsheet_id).execute() + ) + sheets = sheet_metadata.get("sheets", "") + + for sheet in sheets: + title = sheet.get("properties", {}).get("title") + if title == title_to_find: + return sheet.get("properties", {}).get("sheetId") + + return None diff --git a/bot/sheet_service/utils.py b/bot/sheet_service/utils.py new file mode 100644 index 0000000..8d88d88 --- /dev/null +++ b/bot/sheet_service/utils.py @@ -0,0 +1,26 @@ +""" +utils.py + +This file contains utility functions for the Google Sheet Services. + +""" + + +def create_range(sheet, start_col, start_row, end_col=None, end_row=None): + """ + Create a standard range string. + Sample output: "Dropdown!A2:A9" + """ + end_part = f":{end_col}{end_row}" if end_col and end_row else "" + return f"{sheet}!{start_col}{start_row}{end_part}" + + +def create_complex_range(sheet, start_col_ord, end_col_ord, row_start, row_end): + """ + Create a range string for complex cases. + Sample output: ["Dropdown!B2:B9", "Dropdown!C2:C9", ...] + """ + return [ + f"{sheet}!{chr(i)}{row_start}:{chr(i)}{row_end}" + for i in range(start_col_ord, end_col_ord + 1) + ] diff --git a/release_notes.md b/release_notes.md index d5d110a..36c5181 100644 --- a/release_notes.md +++ b/release_notes.md @@ -1,4 +1,11 @@ # Release Notes +## Version 2.3.0 - Date 16 Mar 2024 +### For Developer 🧑‍💻 +- Create new database module which is currently in use for future maintenance and development purposes + +### Changes 🛠️ +- Censor URL for error message + ## Version 2.2.1 - Date 24 Feb 2024 ### Changes 🛠️ - Include error message when replying back to users