4 Commits

Author SHA1 Message Date
Daniel Dybing
1a09921fcc Cleanup: Untrack and ignore PDF documentation files
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 50s
Build Tamigo CLI / Build Windows Binary (push) Successful in 44s
Release Tamigo CLI / Build & Release Linux (release) Successful in 43s
Release Tamigo CLI / Build & Release Windows (release) Successful in 41s
2026-03-13 22:32:31 +01:00
Daniel Dybing
99445a12e2 Feature: Add export functionality for worked hours (CSV, JSON, XLSX)
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 52s
Build Tamigo CLI / Build Windows Binary (push) Successful in 47s
2026-03-13 22:30:32 +01:00
Daniel Dybing
0271432b23 UI: Final Pip-Boy theme adjustments and ASCII fix
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 49s
Build Tamigo CLI / Build Windows Binary (push) Successful in 41s
Release Tamigo CLI / Build & Release Linux (release) Successful in 41s
Release Tamigo CLI / Build & Release Windows (release) Successful in 38s
2026-03-11 21:30:30 +01:00
Daniel Dybing
4ca3e7b29c UI: Implement Fallout Pip-Boy inspired terminal theme 2026-03-11 21:17:15 +01:00
8 changed files with 386 additions and 262 deletions

10
.gitignore vendored
View File

@@ -35,3 +35,13 @@ env/
# OS specific # OS specific
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Local config and exports
config.json
tamigo_export_*.csv
tamigo_export_*.json
tamigo_export_*.xlsx
# PDFs and metadata
*.pdf
*.pdf:Zone.Identifier

View File

@@ -3,3 +3,4 @@ questionary
rich rich
python-dotenv python-dotenv
pyinstaller pyinstaller
openpyxl

506
tamigo.py
View File

@@ -2,6 +2,9 @@ import requests
import questionary import questionary
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from rich.theme import Theme
from rich.panel import Panel
from rich.text import Text
import json import json
import os import os
import re import re
@@ -11,29 +14,109 @@ from dotenv import load_dotenv
# Load environment variables from .env if it exists # Load environment variables from .env if it exists
load_dotenv() load_dotenv()
console = Console() CONFIG_FILE = "config.json"
def load_config():
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r") as f:
return json.load(f)
except:
pass
return {"theme": "fallout"} # Default to fallout because it's cooler
def save_config(config):
with open(CONFIG_FILE, "w") as f:
json.dump(config, f)
# --- Themes ---
THEMES = {
"fallout": {
"rich": Theme({
"info": "bold green",
"warning": "bold yellow",
"error": "bold red",
"success": "bold green",
"dim": "dim green",
"highlight": "bold #33ff33",
"table.header": "bold #33ff33",
"table.title": "bold #33ff33",
}),
"questionary": questionary.Style([
('qmark', 'fg:#33ff33 bold'),
('question', 'fg:#33ff33 bold'),
('answer', 'fg:#ffb000 bold'),
('pointer', 'fg:#33ff33 bold'),
('highlighted', 'fg:#000000 bg:#33ff33 bold'), # Reversed: Black on Green
('selected', 'fg:#33ff33'),
('separator', 'fg:#004400'),
('instruction', 'fg:#004400 italic'),
('text', 'fg:#ffb000'),
('disabled', 'fg:#004400 italic')
]),
"header_text": "[bold #33ff33]ROBCO INDUSTRIES (TM) TERMLINK PROTOCOL[/bold #33ff33]",
"welcome_text": "[highlight]WELCOME TO TAMIGO-OS v1.0.0[/highlight]"
},
"normal": {
"rich": Theme({
"info": "blue",
"warning": "yellow",
"error": "red",
"success": "green",
"dim": "dim white",
"highlight": "bold blue",
"table.header": "bold magenta",
"table.title": "bold cyan",
}),
"questionary": questionary.Style([]), # Default style
"header_text": "[bold blue]Tamigo CLI Official Interface[/bold blue]",
"welcome_text": "[bold blue]Welcome to Tamigo CLI[/bold blue]"
}
}
# Initialize with default or loaded config
current_config = load_config()
current_theme_name = current_config.get("theme", "fallout")
theme_data = THEMES[current_theme_name]
console = Console(theme=theme_data["rich"])
PIPO_ART = r"""
_____ __ __ _____ _____ ____
|_ _| /\ | \/ |_ _/ ____|/ __ \
| | / \ | \ / | | || | __| | | |
| | / /\ \ | |\/| | | || | |_ | | | |
_| |_ / ____ \| | | |_| || |__| | |__| |
|_____/_/ \_\_| |_|_____\_____|\____/
"""
def print_header():
if current_theme_name == "fallout":
console.print(Panel(Text(PIPO_ART, style="highlight"), border_style="highlight", expand=False))
console.print(theme_data["header_text"])
console.print("[dim]-------------------------------------------[/dim]")
console.print(theme_data["welcome_text"])
console.print("[dim]-------------------------------------------[/dim]\n")
# Re-initialize globals when theme changes
def apply_theme(name):
global console, theme_data, current_theme_name
current_theme_name = name
theme_data = THEMES[name]
console = Console(theme=theme_data["rich"])
current_config["theme"] = name
save_config(current_config)
BASE_URL = "https://api.tamigo.com" BASE_URL = "https://api.tamigo.com"
def parse_tamigo_date(date_str): def parse_tamigo_date(date_str):
""" if not date_str: return None
Parses Tamigo date formats:
- /Date(1600898400000+0200)/
- 2023-10-27T08:00:00
"""
if not date_str:
return None
# Handle /Date(1600898400000+0200)/
match = re.search(r"/Date\((\d+)([+-]\d+)?\)/", date_str) match = re.search(r"/Date\((\d+)([+-]\d+)?\)/", date_str)
if match: if match:
ms = int(match.group(1)) ms = int(match.group(1))
# Convert ms to seconds
return datetime.fromtimestamp(ms / 1000.0) return datetime.fromtimestamp(ms / 1000.0)
# Handle ISO format
try: try:
# datetime.fromisoformat handles T and optional Z/+offset in Python 3.7+
return datetime.fromisoformat(date_str.replace("Z", "+00:00")) return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except: except:
return None return None
@@ -42,304 +125,203 @@ class TamigoClient:
def __init__(self): def __init__(self):
self.session_token = None self.session_token = None
self.user_info = None self.user_info = None
self.employee_id = None
def login(self, email, password): def login(self, email, password):
# Try different URL patterns based on docs and common patterns urls = [f"{BASE_URL}/login/application", f"{BASE_URL}/Login", f"{BASE_URL}/login"]
urls = [
f"{BASE_URL}/login/application",
f"{BASE_URL}/Login",
f"{BASE_URL}/Login/",
f"{BASE_URL}/login",
f"{BASE_URL}/login/"
]
last_error = ""
for url in urls: for url in urls:
payload = { payload = {"Email": email, "Password": password, "Name": "TamigoCLI", "Key": password}
"Email": email,
"Password": password,
"Name": "TamigoCLI", # For /login/application
"Key": password # For /login/application
}
try: try:
headers = { headers = {"Content-Type": "application/json", "Accept": "application/json"}
"Content-Type": "application/json",
"Accept": "application/json"
}
response = requests.post(url, json=payload, headers=headers, timeout=15) response = requests.post(url, json=payload, headers=headers, timeout=15)
if response.status_code == 200:
try:
data = response.json()
# Token can be in different fields
self.session_token = data.get("SessionToken") or data.get("securitytoken") or data.get("Token")
if self.session_token:
self.user_info = data
self.employee_id = data.get("EmployeeId")
return True
except json.JSONDecodeError:
text = response.text.strip().strip('"')
if text and len(text) > 20:
self.session_token = text
self.user_info = {"Email": email}
return True
last_error = f"URL: {url}, Status: {response.status_code}"
except Exception as e:
last_error = f"URL: {url}, Error: {str(e)}"
console.print(f"[red]Login failed.[/red]")
console.print(f"[dim]Debug: {last_error}[/dim]")
return False
def get_employee_id(self):
if self.employee_id:
return self.employee_id
headers = {
"x-tamigo-token": self.session_token,
"securitytoken": self.session_token,
"Accept": "application/json"
}
# Method A: User Info from Login
if self.user_info and self.user_info.get("EmployeeId"):
self.employee_id = self.user_info.get("EmployeeId")
return self.employee_id
# Method B: My Overview
url = f"{BASE_URL}/shifts/myoverview"
try:
response = requests.get(url, headers=headers)
if response.status_code == 200:
shifts = response.json()
if shifts and len(shifts) > 0:
self.employee_id = shifts[0].get("EmployeeId")
return self.employee_id
except:
pass
return None
def get_employee_actual_shifts(self, start_date_dt, end_date_dt):
"""
Fetches actual worked shifts using employee-accessible endpoints.
Tamigo's 'past' endpoint often limits to 60 days, so we fetch in chunks.
"""
if not self.session_token:
return None
headers = {
"x-tamigo-token": self.session_token,
"securitytoken": self.session_token,
"Accept": "application/json"
}
all_shifts = []
days_diff = (end_date_dt - start_date_dt).days
# Fetch in 60-day chunks moving backwards from end_date
for i in range(0, days_diff + 1, 60):
target_date = (end_date_dt - timedelta(days=i)).strftime("%Y-%m-%d")
# Stop if we've moved past the start date
current_dt = end_date_dt - timedelta(days=i)
if current_dt < start_date_dt - timedelta(days=60):
break
url = f"{BASE_URL}/actualshifts/past/{target_date}"
try:
response = requests.get(url, headers=headers)
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
if isinstance(data, list): self.session_token = data.get("SessionToken") or data.get("securitytoken") or data.get("Token")
all_shifts.extend(data) if self.session_token:
elif response.status_code == 401: self.user_info = data
response = requests.get(url, params={"securitytoken": self.session_token}) return True
if response.status_code == 200: except: continue
all_shifts.extend(response.json()) return False
except Exception as e:
console.print(f"[dim]Failed to fetch chunk at {target_date}: {e}[/dim]")
# Supplement with /shifts/period/
start_str = start_date_dt.strftime("%Y-%m-%d")
end_str = end_date_dt.strftime("%Y-%m-%d")
url_period = f"{BASE_URL}/shifts/period/{start_str}/{end_str}"
try:
response = requests.get(url_period, headers=headers)
if response.status_code == 200:
data = response.json()
if isinstance(data, list):
all_shifts.extend(data)
except:
pass
def get_employee_actual_shifts(self, start_date_dt, end_date_dt):
if not self.session_token: return None
headers = {"x-tamigo-token": self.session_token, "securitytoken": self.session_token, "Accept": "application/json"}
all_shifts = []
days_diff = (end_date_dt - start_date_dt).days
for i in range(0, days_diff + 1, 60):
target_date = (end_date_dt - timedelta(days=i)).strftime("%Y-%m-%d")
if (end_date_dt - timedelta(days=i)) < start_date_dt - timedelta(days=60): break
try:
response = requests.get(f"{BASE_URL}/actualshifts/past/{target_date}", headers=headers)
if response.status_code == 200: all_shifts.extend(response.json())
elif response.status_code == 401:
resp = requests.get(f"{BASE_URL}/actualshifts/past/{target_date}", params={"securitytoken": self.session_token})
if resp.status_code == 200: all_shifts.extend(resp.json())
except: pass
try:
response = requests.get(f"{BASE_URL}/shifts/period/{start_date_dt.strftime('%Y-%m-%d')}/{end_date_dt.strftime('%Y-%m-%d')}", headers=headers)
if response.status_code == 200: all_shifts.extend(response.json())
except: pass
return all_shifts return all_shifts
def calculate_checkins(client): def select_date_range():
range_choice = questionary.select( range_choice = questionary.select(
"Select period:", "SELECT TEMPORAL RANGE:" if current_theme_name == "fallout" else "Select period:",
choices=[ choices=["Last 365 days", "Last 30 days", "This Month", "This Year", "Custom range..."],
"Last 365 days", style=theme_data["questionary"]
"Last 30 days",
"This Month",
"This Year",
"Custom range..."
]
).ask() ).ask()
end_date_dt = datetime.now() end_date_dt = datetime.now()
if range_choice == "Last 365 days": start_date_dt = end_date_dt - timedelta(days=365)
if range_choice == "Last 365 days": elif range_choice == "Last 30 days": start_date_dt = end_date_dt - timedelta(days=30)
start_date_dt = end_date_dt - timedelta(days=365) elif range_choice == "This Month": start_date_dt = end_date_dt.replace(day=1)
elif range_choice == "Last 30 days": elif range_choice == "This Year": start_date_dt = end_date_dt.replace(month=1, day=1)
start_date_dt = end_date_dt - timedelta(days=30)
elif range_choice == "This Month":
start_date_dt = end_date_dt.replace(day=1)
elif range_choice == "This Year":
start_date_dt = end_date_dt.replace(month=1, day=1)
else: else:
# Custom range
while True: while True:
start_str = questionary.text("Start date (YYYY-MM-DD):", default=(datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")).ask() start_str = questionary.text("ENTER START DATE (YYYY-MM-DD):" if current_theme_name == "fallout" else "Start date (YYYY-MM-DD):", default=(datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d"), style=theme_data["questionary"]).ask()
end_str = questionary.text("End date (YYYY-MM-DD):", default=datetime.now().strftime("%Y-%m-%d")).ask() end_str = questionary.text("ENTER END DATE (YYYY-MM-DD):" if current_theme_name == "fallout" else "End date (YYYY-MM-DD):", default=datetime.now().strftime("%Y-%m-%d"), style=theme_data["questionary"]).ask()
try: try:
start_date_dt = datetime.strptime(start_str, "%Y-%m-%d") start_date_dt, end_date_dt = datetime.strptime(start_str, "%Y-%m-%d"), datetime.strptime(end_str, "%Y-%m-%d")
end_date_dt = datetime.strptime(end_str, "%Y-%m-%d") if start_date_dt <= end_date_dt: break
if start_date_dt > end_date_dt: console.print("[error]ERROR: INVALID CHRONOLOGY.[/error]")
console.print("[red]Start date must be before end date![/red]") except: console.print("[error]ERROR: INVALID DATA FORMAT.[/error]")
continue return start_date_dt, end_date_dt
break
except ValueError:
console.print("[red]Invalid format. Please use YYYY-MM-DD.[/red]")
with console.status(f"[bold green]Fetching work history from {start_date_dt.strftime('%Y-%m-%d')} to {end_date_dt.strftime('%Y-%m-%d')}..."): 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) data = client.get_employee_actual_shifts(start_date_dt, end_date_dt)
if data: if data:
work_days = {} # date_str -> {hours, text} work_days = {}
# Filter data to ensure it's strictly within our requested range
# (Since the API chunks might return slightly more)
requested_start = start_date_dt.date()
requested_end = end_date_dt.date()
for item in data: for item in data:
raw_date = item.get("Date") or item.get("StartTime") or item.get("CheckInTime") dt = parse_tamigo_date(item.get("Date") or item.get("StartTime") or item.get("CheckInTime"))
dt = parse_tamigo_date(raw_date) if dt and start_date_dt.date() <= dt.date() <= end_date_dt.date():
if dt:
item_date = dt.date()
if not (requested_start <= item_date <= requested_end):
continue
date_str = dt.strftime("%Y-%m-%d") date_str = dt.strftime("%Y-%m-%d")
is_absent = item.get("IsAbsent", False) hours = float(item.get("ActualShiftHours") or item.get("CheckInOutShiftHours") or item.get("Hours") or 0)
hours = (item.get("ActualShiftHours") or
item.get("CheckInOutShiftHours") or
item.get("Hours") or 0)
if hours == 0 and item.get("StartTime") and item.get("EndTime"): if hours == 0 and item.get("StartTime") and item.get("EndTime"):
st = parse_tamigo_date(item.get("StartTime")) st, et = parse_tamigo_date(item.get("StartTime")), parse_tamigo_date(item.get("EndTime"))
et = parse_tamigo_date(item.get("EndTime")) if st and et: hours = (et - st).total_seconds() / 3600.0
if st and et: if (hours > 0 or item.get("CheckInTime") or item.get("ActualStartTime") or item.get("StartTime")) and not item.get("IsAbsent", False):
hours = (et - st).total_seconds() / 3600.0 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
has_actual = False def calculate_checkins(client):
if hours > 0 or item.get("CheckInTime") or item.get("ActualStartTime"): start_date_dt, end_date_dt = select_date_range()
has_actual = True work_days = get_worked_days(client, start_date_dt, end_date_dt)
if item.get("ActualShift") and item["ActualShift"].get("Shift", 0) > 0:
has_actual = True if work_days is not None:
hours = item["ActualShift"]["Shift"]
if item.get("StartTime") and not is_absent:
has_actual = True
if has_actual and not is_absent:
if date_str not in work_days:
work_days[date_str] = {
"hours": float(hours),
"text": item.get("ActualShiftText") or item.get("ActivityName") or item.get("DepartmentName") or "Worked"
}
else:
work_days[date_str]["hours"] += float(hours)
if not work_days: if not work_days:
console.print("[yellow]No work records found for this period.[/yellow]") console.print("[warning]WARNING: NO LOG ENTRIES DETECTED.[/warning]")
return return
all_dates = sorted(work_days.keys(), reverse=True) all_dates = sorted(work_days.keys(), reverse=True)
table = Table(title="LOG ENTRIES: ACTUAL WORKED SHIFTS" if current_theme_name == "fallout" else "Recent Work Days", show_header=True, header_style="table.header", box=None if current_theme_name == "fallout" else None)
table = Table(title="Worked Days in Period", show_header=True, header_style="bold magenta") table.add_column("DATE", style="highlight")
table.add_column("Date", style="cyan") table.add_column("HOURS", justify="right")
table.add_column("Hours", justify="right") table.add_column("DETAILS", style="dim")
table.add_column("Details", style="dim")
for day in all_dates: for day in all_dates:
info = work_days[day] info = work_days[day]
table.add_row(day, f"{info['hours']:.2f}", str(info['text'])) table.add_row(day, f"{info['hours']:.2f}", str(info['text']).upper() if current_theme_name == "fallout" else str(info['text']))
console.print(table) console.print(table)
console.print(f"\n[bold]Work Statistics:[/bold]") summary_text = f"PERIOD: {start_date_dt.strftime('%Y-%m-%d')} TO {end_date_dt.strftime('%Y-%m-%d')}\nDAYS WORKED: {len(all_dates)}\nTOTAL HOURS: {sum(d['hours'] for d in work_days.values()):.2f}"
console.print(f" - Period: [cyan]{start_date_dt.strftime('%Y-%m-%d')} to {end_date_dt.strftime('%Y-%m-%d')}[/cyan]") console.print(Panel(summary_text, title="SUMMARY STATISTICS" if current_theme_name == "fallout" else "Work Statistics", border_style="highlight", expand=False))
console.print(f" - Days Worked: [bold green]{len(all_dates)}[/bold green]") else: console.print("[error]FATAL ERROR: DATA RETRIEVAL FAILURE.[/error]")
total_hours = sum(d['hours'] for d in work_days.values())
console.print(f" - Total Hours: [bold green]{total_hours:.2f}[/bold green]")
else:
console.print("[yellow]Could not retrieve any shift data for this period.[/yellow]")
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()
def show_profile(client): default_filename = f"tamigo_export_{start_date_dt.strftime('%Y%m%d')}_{end_date_dt.strftime('%Y%m%d')}"
if client.user_info: filename = questionary.text(
console.print_json(data=client.user_info) "ENTER FILENAME:" if current_theme_name == "fallout" else "Enter filename:",
else: default=f"{default_filename}.{format_choice.lower()}",
console.print("[yellow]No profile info available. Are you logged in?[/yellow]") 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(): def main():
print_header()
client = TamigoClient() client = TamigoClient()
email = os.getenv("TAMIGO_EMAIL") or questionary.text("ENTER IDENTIFIER (EMAIL):" if current_theme_name == "fallout" else "Email:", style=theme_data["questionary"]).ask()
console.print("[bold blue]Welcome to Tamigo CLI[/bold blue]") password = os.getenv("TAMIGO_PASSWORD") or questionary.password("ENTER ACCESS KEY:" if current_theme_name == "fallout" else "Password:", style=theme_data["questionary"]).ask()
if not email or not password: return
email = os.getenv("TAMIGO_EMAIL") with console.status("[highlight]AUTHENTICATING...[/highlight]" if current_theme_name == "fallout" else "Logging in..."):
if not email:
email = questionary.text("Email:").ask()
password = os.getenv("TAMIGO_PASSWORD")
if not password:
password = questionary.password("Password:").ask()
if not email or not password:
return
with console.status("[bold green]Logging in..."):
success = client.login(email, password) success = client.login(email, password)
if success: if success:
console.print("[bold green]Login successful![/bold green]") console.print("[success]AUTHENTICATION SUCCESSFUL.[/success]" if current_theme_name == "fallout" else "[success]Login successful![/success]")
menu(client) menu(client)
else: else: console.print("[error]CRITICAL FAILURE: INVALID CREDENTIALS.[/error]")
console.print("[bold red]Login failed. Please check your credentials.[/bold red]")
def menu(client): def menu(client):
while True: while True:
choice = questionary.select( choice = questionary.select(
"What would you like to do?", "SELECT ACTION:" if current_theme_name == "fallout" else "What would you like to do?",
choices=[ choices=["Calculate actual work days", "Export hours worked", "Show profile info", "Switch UI Theme", "Logout and Exit"],
"Calculate actual work days", style=theme_data["questionary"]
"Show profile info",
"Logout and Exit"
]
).ask() ).ask()
if choice == "Calculate actual work days": if choice == "Calculate actual work days": calculate_checkins(client)
calculate_checkins(client) elif choice == "Export hours worked": export_worked_hours(client)
elif choice == "Show profile info": elif choice == "Show profile info":
show_profile(client) if client.user_info: console.print_json(data=client.user_info)
else: console.print("[error]ERROR: NO PROFILE INFO.[/error]")
elif choice == "Switch UI Theme":
new_theme = "normal" if current_theme_name == "fallout" else "fallout"
apply_theme(new_theme)
console.print(f"[success]UI THEME SET TO: {new_theme.upper()}[/success]")
else: else:
console.print("[highlight]SYSTEM SHUTDOWN...[/highlight]" if current_theme_name == "fallout" else "Exiting...")
break break
if __name__ == "__main__": if __name__ == "__main__":

131
test_export.py Normal file
View File

@@ -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()