diff --git a/.gitignore b/.gitignore index 65a1b11..b850ae7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ env/ # OS specific .DS_Store Thumbs.db + +# Local config and exports +config.json +tamigo_export_*.csv +tamigo_export_*.json +tamigo_export_*.xlsx diff --git a/requirements.txt b/requirements.txt index 309f7d2..003aee9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ questionary rich python-dotenv pyinstaller +openpyxl diff --git a/tamigo.py b/tamigo.py index 8751d39..d31683d 100644 --- a/tamigo.py +++ b/tamigo.py @@ -163,7 +163,7 @@ class TamigoClient: except: pass return all_shifts -def calculate_checkins(client): +def select_date_range(): range_choice = questionary.select( "SELECT TEMPORAL RANGE:" if current_theme_name == "fallout" else "Select period:", choices=["Last 365 days", "Last 30 days", "This Month", "This Year", "Custom range..."], @@ -184,7 +184,9 @@ def calculate_checkins(client): if start_date_dt <= end_date_dt: break console.print("[error]ERROR: INVALID CHRONOLOGY.[/error]") except: console.print("[error]ERROR: INVALID DATA FORMAT.[/error]") + return start_date_dt, end_date_dt +def get_worked_days(client, start_date_dt, end_date_dt): with console.status("[highlight]INITIALIZING DATA RETRIEVAL...[/highlight]" if current_theme_name == "fallout" else "Fetching data..."): data = client.get_employee_actual_shifts(start_date_dt, end_date_dt) @@ -201,7 +203,14 @@ def calculate_checkins(client): if (hours > 0 or item.get("CheckInTime") or item.get("ActualStartTime") or item.get("StartTime")) and not item.get("IsAbsent", False): if date_str not in work_days: work_days[date_str] = {"hours": hours, "text": item.get("ActualShiftText") or item.get("ActivityName") or "WORKED"} else: work_days[date_str]["hours"] += hours - + return work_days + return None + +def calculate_checkins(client): + start_date_dt, end_date_dt = select_date_range() + work_days = get_worked_days(client, start_date_dt, end_date_dt) + + if work_days is not None: if not work_days: console.print("[warning]WARNING: NO LOG ENTRIES DETECTED.[/warning]") return @@ -220,6 +229,67 @@ def calculate_checkins(client): console.print(Panel(summary_text, title="SUMMARY STATISTICS" if current_theme_name == "fallout" else "Work Statistics", border_style="highlight", expand=False)) else: console.print("[error]FATAL ERROR: DATA RETRIEVAL FAILURE.[/error]") +def export_worked_hours(client): + start_date_dt, end_date_dt = select_date_range() + work_days = get_worked_days(client, start_date_dt, end_date_dt) + + if work_days is not None: + if not work_days: + console.print("[warning]WARNING: NO LOG ENTRIES DETECTED.[/warning]") + return + + format_choice = questionary.select( + "SELECT EXPORT FORMAT:" if current_theme_name == "fallout" else "Select export format:", + choices=["CSV", "JSON", "XLSX"], + style=theme_data["questionary"] + ).ask() + + default_filename = f"tamigo_export_{start_date_dt.strftime('%Y%m%d')}_{end_date_dt.strftime('%Y%m%d')}" + filename = questionary.text( + "ENTER FILENAME:" if current_theme_name == "fallout" else "Enter filename:", + default=f"{default_filename}.{format_choice.lower()}", + style=theme_data["questionary"] + ).ask() + + if not filename: return + + try: + if format_choice == "CSV": + import csv + with open(filename, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(["Date", "Hours", "Details"]) + for day in sorted(work_days.keys()): + info = work_days[day] + writer.writerow([day, f"{info['hours']:.2f}", info['text']]) + elif format_choice == "XLSX": + from openpyxl import Workbook + from openpyxl.styles import Font + wb = Workbook() + ws = wb.active + ws.title = "Worked Hours" + + # Header + headers = ["Date", "Hours", "Details"] + ws.append(headers) + for cell in ws[1]: + cell.font = Font(bold=True) + + # Data + for day in sorted(work_days.keys()): + info = work_days[day] + ws.append([day, info['hours'], info['text']]) + + wb.save(filename) + else: + with open(filename, 'w') as f: + json.dump(work_days, f, indent=4) + + console.print(f"[success]DATA EXPORTED SUCCESSFULLY TO: {filename}[/success]") + except Exception as e: + console.print(f"[error]ERROR SAVING FILE: {str(e)}[/error]") + else: console.print("[error]FATAL ERROR: DATA RETRIEVAL FAILURE.[/error]") + def main(): print_header() client = TamigoClient() @@ -237,11 +307,12 @@ def menu(client): while True: choice = questionary.select( "SELECT ACTION:" if current_theme_name == "fallout" else "What would you like to do?", - choices=["Calculate actual work days", "Show profile info", "Switch UI Theme", "Logout and Exit"], + choices=["Calculate actual work days", "Export hours worked", "Show profile info", "Switch UI Theme", "Logout and Exit"], style=theme_data["questionary"] ).ask() if choice == "Calculate actual work days": calculate_checkins(client) + elif choice == "Export hours worked": export_worked_hours(client) elif choice == "Show profile info": if client.user_info: console.print_json(data=client.user_info) else: console.print("[error]ERROR: NO PROFILE INFO.[/error]") diff --git a/test_export.py b/test_export.py new file mode 100644 index 0000000..bee7040 --- /dev/null +++ b/test_export.py @@ -0,0 +1,131 @@ +import unittest +from unittest.mock import MagicMock, patch +from datetime import datetime, timedelta +import json +import os +import csv +from tamigo import get_worked_days, TamigoClient + +class TestTamigoExport(unittest.TestCase): + def setUp(self): + self.client = MagicMock(spec=TamigoClient) + self.client.session_token = "fake-token" + + def test_get_worked_days_processing(self): + # Mock API response + mock_shifts = [ + { + "Date": "/Date(1741651200000)/", # 2025-03-11 + "ActualShiftHours": 8.5, + "ActualShiftText": "Work", + "IsAbsent": False + }, + { + "StartTime": "2025-03-10T09:00:00Z", + "EndTime": "2025-03-10T17:00:00Z", + "ActualShiftHours": 0, + "IsAbsent": False + } + ] + self.client.get_employee_actual_shifts.return_value = mock_shifts + + start_date = datetime(2025, 3, 1) + end_date = datetime(2025, 3, 15) + + with patch('tamigo.console'): + work_days = get_worked_days(self.client, start_date, end_date) + + self.assertIsNotNone(work_days) + self.assertIn("2025-03-11", work_days) + self.assertEqual(work_days["2025-03-11"]["hours"], 8.5) + self.assertIn("2025-03-10", work_days) + self.assertEqual(work_days["2025-03-10"]["hours"], 8.0) + + @patch('tamigo.select_date_range') + @patch('tamigo.get_worked_days') + @patch('questionary.select') + @patch('questionary.text') + @patch('tamigo.console') + def test_export_worked_hours_csv(self, mock_console, mock_text, mock_select, mock_get_worked, mock_date_range): + from tamigo import export_worked_hours + + mock_date_range.return_value = (datetime(2025, 3, 1), datetime(2025, 3, 15)) + mock_get_worked.return_value = { + "2025-03-11": {"hours": 8.5, "text": "Work"}, + "2025-03-10": {"hours": 8.0, "text": "Work"} + } + + mock_select.return_value.ask.return_value = "CSV" + mock_text.return_value.ask.return_value = "test_export.csv" + + export_worked_hours(self.client) + + self.assertTrue(os.path.exists("test_export.csv")) + with open("test_export.csv", "r") as f: + reader = csv.reader(f) + rows = list(reader) + self.assertEqual(rows[0], ["Date", "Hours", "Details"]) + # CSV rows might be in different order if dict keys are not sorted, but sorted() was used in code + self.assertEqual(rows[1], ["2025-03-10", "8.00", "Work"]) + self.assertEqual(rows[2], ["2025-03-11", "8.50", "Work"]) + + os.remove("test_export.csv") + + @patch('tamigo.select_date_range') + @patch('tamigo.get_worked_days') + @patch('questionary.select') + @patch('questionary.text') + @patch('tamigo.console') + def test_export_worked_hours_json(self, mock_console, mock_text, mock_select, mock_get_worked, mock_date_range): + from tamigo import export_worked_hours + + mock_date_range.return_value = (datetime(2025, 3, 1), datetime(2025, 3, 15)) + mock_get_worked.return_value = { + "2025-03-11": {"hours": 8.5, "text": "Work"}, + "2025-03-10": {"hours": 8.0, "text": "Work"} + } + + mock_select.return_value.ask.return_value = "JSON" + mock_text.return_value.ask.return_value = "test_export.json" + + export_worked_hours(self.client) + + self.assertTrue(os.path.exists("test_export.json")) + with open("test_export.json", "r") as f: + data = json.load(f) + self.assertEqual(data["2025-03-11"]["hours"], 8.5) + + os.remove("test_export.json") + + @patch('tamigo.select_date_range') + @patch('tamigo.get_worked_days') + @patch('questionary.select') + @patch('questionary.text') + @patch('tamigo.console') + def test_export_worked_hours_xlsx(self, mock_console, mock_text, mock_select, mock_get_worked, mock_date_range): + from tamigo import export_worked_hours + + mock_date_range.return_value = (datetime(2025, 3, 1), datetime(2025, 3, 15)) + mock_get_worked.return_value = { + "2025-03-11": {"hours": 8.5, "text": "Work"}, + "2025-03-10": {"hours": 8.0, "text": "Work"} + } + + mock_select.return_value.ask.return_value = "XLSX" + mock_text.return_value.ask.return_value = "test_export.xlsx" + + export_worked_hours(self.client) + + self.assertTrue(os.path.exists("test_export.xlsx")) + # We could use openpyxl to verify content if we really wanted to + import openpyxl + wb = openpyxl.load_workbook("test_export.xlsx") + ws = wb.active + self.assertEqual(ws.cell(row=1, column=1).value, "Date") + self.assertEqual(ws.cell(row=2, column=1).value, "2025-03-10") + self.assertEqual(ws.cell(row=2, column=2).value, 8.0) + + os.remove("test_export.xlsx") + +if __name__ == '__main__': + unittest.main()