Files
tamigo-cli/tamigo.py
Daniel Dybing 99445a12e2
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 52s
Build Tamigo CLI / Build Windows Binary (push) Successful in 47s
Feature: Add export functionality for worked hours (CSV, JSON, XLSX)
2026-03-13 22:30:32 +01:00

329 lines
15 KiB
Python

import requests
import questionary
from rich.console import Console
from rich.table import Table
from rich.theme import Theme
from rich.panel import Panel
from rich.text import Text
import json
import os
import re
from datetime import datetime, timedelta
from dotenv import load_dotenv
# Load environment variables from .env if it exists
load_dotenv()
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"
def parse_tamigo_date(date_str):
if not date_str: return None
match = re.search(r"/Date\((\d+)([+-]\d+)?\)/", date_str)
if match:
ms = int(match.group(1))
return datetime.fromtimestamp(ms / 1000.0)
try:
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except:
return None
class TamigoClient:
def __init__(self):
self.session_token = None
self.user_info = None
def login(self, email, password):
urls = [f"{BASE_URL}/login/application", f"{BASE_URL}/Login", f"{BASE_URL}/login"]
for url in urls:
payload = {"Email": email, "Password": password, "Name": "TamigoCLI", "Key": password}
try:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
response = requests.post(url, json=payload, headers=headers, timeout=15)
if response.status_code == 200:
data = response.json()
self.session_token = data.get("SessionToken") or data.get("securitytoken") or data.get("Token")
if self.session_token:
self.user_info = data
return True
except: continue
return False
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
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..."],
style=theme_data["questionary"]
).ask()
end_date_dt = datetime.now()
if range_choice == "Last 365 days": start_date_dt = end_date_dt - timedelta(days=365)
elif range_choice == "Last 30 days": 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:
while True:
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("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:
start_date_dt, end_date_dt = datetime.strptime(start_str, "%Y-%m-%d"), datetime.strptime(end_str, "%Y-%m-%d")
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)
if data:
work_days = {}
for item in data:
dt = parse_tamigo_date(item.get("Date") or item.get("StartTime") or item.get("CheckInTime"))
if dt and start_date_dt.date() <= dt.date() <= end_date_dt.date():
date_str = dt.strftime("%Y-%m-%d")
hours = float(item.get("ActualShiftHours") or item.get("CheckInOutShiftHours") or item.get("Hours") or 0)
if hours == 0 and item.get("StartTime") and item.get("EndTime"):
st, et = parse_tamigo_date(item.get("StartTime")), parse_tamigo_date(item.get("EndTime"))
if st and et: hours = (et - st).total_seconds() / 3600.0
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
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.add_column("DATE", style="highlight")
table.add_column("HOURS", justify="right")
table.add_column("DETAILS", style="dim")
for day in all_dates:
info = work_days[day]
table.add_row(day, f"{info['hours']:.2f}", str(info['text']).upper() if current_theme_name == "fallout" else str(info['text']))
console.print(table)
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(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()
email = os.getenv("TAMIGO_EMAIL") or questionary.text("ENTER IDENTIFIER (EMAIL):" if current_theme_name == "fallout" else "Email:", style=theme_data["questionary"]).ask()
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
with console.status("[highlight]AUTHENTICATING...[/highlight]" if current_theme_name == "fallout" else "Logging in..."):
success = client.login(email, password)
if success:
console.print("[success]AUTHENTICATION SUCCESSFUL.[/success]" if current_theme_name == "fallout" else "[success]Login successful![/success]")
menu(client)
else: console.print("[error]CRITICAL FAILURE: INVALID CREDENTIALS.[/error]")
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", "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]")
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:
console.print("[highlight]SYSTEM SHUTDOWN...[/highlight]" if current_theme_name == "fallout" else "Exiting...")
break
if __name__ == "__main__":
main()