Files
tamigo-cli/tamigo.py

347 lines
12 KiB
Python

import requests
import questionary
from rich.console import Console
from rich.table import Table
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()
console = Console()
BASE_URL = "https://api.tamigo.com"
def parse_tamigo_date(date_str):
"""
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)
if match:
ms = int(match.group(1))
# Convert ms to seconds
return datetime.fromtimestamp(ms / 1000.0)
# Handle ISO format
try:
# datetime.fromisoformat handles T and optional Z/+offset in Python 3.7+
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
self.employee_id = None
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/",
f"{BASE_URL}/login",
f"{BASE_URL}/login/"
]
last_error = ""
for url in urls:
payload = {
"Email": email,
"Password": password,
"Name": "TamigoCLI", # For /login/application
"Key": password # For /login/application
}
try:
headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
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:
data = response.json()
if isinstance(data, list):
all_shifts.extend(data)
elif response.status_code == 401:
response = requests.get(url, params={"securitytoken": self.session_token})
if response.status_code == 200:
all_shifts.extend(response.json())
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
return all_shifts
def calculate_checkins(client):
range_choice = questionary.select(
"Select period:",
choices=[
"Last 365 days",
"Last 30 days",
"This Month",
"This Year",
"Custom range..."
]
).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:
# Custom range
while True:
start_str = questionary.text("Start date (YYYY-MM-DD):", default=(datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")).ask()
end_str = questionary.text("End date (YYYY-MM-DD):", default=datetime.now().strftime("%Y-%m-%d")).ask()
try:
start_date_dt = datetime.strptime(start_str, "%Y-%m-%d")
end_date_dt = datetime.strptime(end_str, "%Y-%m-%d")
if start_date_dt > end_date_dt:
console.print("[red]Start date must be before end date![/red]")
continue
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')}..."):
data = client.get_employee_actual_shifts(start_date_dt, end_date_dt)
if data:
work_days = {} # date_str -> {hours, text}
# 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:
raw_date = item.get("Date") or item.get("StartTime") or item.get("CheckInTime")
dt = parse_tamigo_date(raw_date)
if dt:
item_date = dt.date()
if not (requested_start <= item_date <= requested_end):
continue
date_str = dt.strftime("%Y-%m-%d")
is_absent = item.get("IsAbsent", False)
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"):
st = parse_tamigo_date(item.get("StartTime"))
et = parse_tamigo_date(item.get("EndTime"))
if st and et:
hours = (et - st).total_seconds() / 3600.0
has_actual = False
if hours > 0 or item.get("CheckInTime") or item.get("ActualStartTime"):
has_actual = True
if item.get("ActualShift") and item["ActualShift"].get("Shift", 0) > 0:
has_actual = True
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:
console.print("[yellow]No work records found for this period.[/yellow]")
return
all_dates = sorted(work_days.keys(), reverse=True)
table = Table(title="Worked Days in Period", show_header=True, header_style="bold magenta")
table.add_column("Date", style="cyan")
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']))
console.print(table)
console.print(f"\n[bold]Work Statistics:[/bold]")
console.print(f" - Period: [cyan]{start_date_dt.strftime('%Y-%m-%d')} to {end_date_dt.strftime('%Y-%m-%d')}[/cyan]")
console.print(f" - Days Worked: [bold green]{len(all_dates)}[/bold green]")
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 show_profile(client):
if client.user_info:
console.print_json(data=client.user_info)
else:
console.print("[yellow]No profile info available. Are you logged in?[/yellow]")
def main():
client = TamigoClient()
console.print("[bold blue]Welcome to Tamigo CLI[/bold blue]")
email = os.getenv("TAMIGO_EMAIL")
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)
if success:
console.print("[bold green]Login successful![/bold green]")
menu(client)
else:
console.print("[bold red]Login failed. Please check your credentials.[/bold red]")
def menu(client):
while True:
choice = questionary.select(
"What would you like to do?",
choices=[
"Calculate actual work days",
"Show profile info",
"Logout and Exit"
]
).ask()
if choice == "Calculate actual work days":
calculate_checkins(client)
elif choice == "Show profile info":
show_profile(client)
else:
break
if __name__ == "__main__":
main()