UI: Implement Fallout Pip-Boy inspired terminal theme

This commit is contained in:
Daniel Dybing
2026-03-11 21:17:15 +01:00
parent 5c818a09e0
commit 4ca3e7b29c

192
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,10 +14,53 @@ 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() # Fallout Pip-Boy Style Theme
fallout_theme = 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",
})
console = Console(theme=fallout_theme)
# Questionary custom style
custom_style = questionary.Style([
('qmark', 'fg:#33ff33 bold'), # token in front of the question
('question', 'fg:#33ff33 bold'), # question text
('answer', 'fg:#33ff33'), # submitted answer text behind the question
('pointer', 'fg:#33ff33 bold'), # pointer used in select input
('highlighted', 'fg:#000000 bg:#33ff33 bold'), # pointed-at choice in select and checkbox
('selected', 'fg:#33ff33'), # pointed-at choice in select and checkbox
('separator', 'fg:#004400'), # separator in lists
('instruction', 'fg:#004400 italic'), # user instructions for checkbox, reorder, etc.
('text', 'fg:#33ff33'), # plain text
('disabled', 'fg:#004400 italic') # disabled choices for select and checkbox
])
PIPO_ART = """
_____ __ __ _____ _____ ____
|_ _| /\ | \/ |_ _/ ____|/ __ \
| | / \ | \ / | | || | __| | | |
| | / /\ \ | |\/| | | || | |_ | | | |
_| |_ / ____ \| | | |_| || |__| | |__| |
|_____/_/ \_\_| |_|_____\_____|\____/
[bold #33ff33]ROBCO INDUSTRIES (TM) TERMLINK PROTOCOL[/bold #33ff33]
"""
BASE_URL = "https://api.tamigo.com" BASE_URL = "https://api.tamigo.com"
def print_header():
console.print(PIPO_ART, style="highlight")
console.print("[dim]-------------------------------------------[/dim]")
console.print("[highlight]WELCOME TO TAMIGO-OS v1.0.0[/highlight]")
console.print("[dim]-------------------------------------------[/dim]\n")
def parse_tamigo_date(date_str): def parse_tamigo_date(date_str):
""" """
Parses Tamigo date formats: Parses Tamigo date formats:
@@ -45,22 +91,18 @@ class TamigoClient:
self.employee_id = 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 = [ urls = [
f"{BASE_URL}/login/application", f"{BASE_URL}/login/application",
f"{BASE_URL}/Login", f"{BASE_URL}/Login",
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, "Email": email,
"Password": password, "Password": password,
"Name": "TamigoCLI", # For /login/application "Name": "TamigoCLI",
"Key": password # For /login/application "Key": password
} }
try: try:
headers = { headers = {
@@ -72,7 +114,6 @@ class TamigoClient:
if response.status_code == 200: if response.status_code == 200:
try: try:
data = response.json() data = response.json()
# Token can be in different fields
self.session_token = data.get("SessionToken") or data.get("securitytoken") or data.get("Token") self.session_token = data.get("SessionToken") or data.get("securitytoken") or data.get("Token")
if self.session_token: if self.session_token:
self.user_info = data self.user_info = data
@@ -84,49 +125,12 @@ class TamigoClient:
self.session_token = text self.session_token = text
self.user_info = {"Email": email} self.user_info = {"Email": email}
return True return True
except:
continue
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 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): 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: if not self.session_token:
return None return None
@@ -139,11 +143,8 @@ class TamigoClient:
all_shifts = [] all_shifts = []
days_diff = (end_date_dt - start_date_dt).days 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): for i in range(0, days_diff + 1, 60):
target_date = (end_date_dt - timedelta(days=i)).strftime("%Y-%m-%d") 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) current_dt = end_date_dt - timedelta(days=i)
if current_dt < start_date_dt - timedelta(days=60): if current_dt < start_date_dt - timedelta(days=60):
break break
@@ -159,10 +160,9 @@ class TamigoClient:
response = requests.get(url, params={"securitytoken": self.session_token}) response = requests.get(url, params={"securitytoken": self.session_token})
if response.status_code == 200: if response.status_code == 200:
all_shifts.extend(response.json()) all_shifts.extend(response.json())
except Exception as e: except:
console.print(f"[dim]Failed to fetch chunk at {target_date}: {e}[/dim]") pass
# Supplement with /shifts/period/
start_str = start_date_dt.strftime("%Y-%m-%d") start_str = start_date_dt.strftime("%Y-%m-%d")
end_str = end_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}" url_period = f"{BASE_URL}/shifts/period/{start_str}/{end_str}"
@@ -179,14 +179,15 @@ class TamigoClient:
def calculate_checkins(client): def calculate_checkins(client):
range_choice = questionary.select( range_choice = questionary.select(
"Select period:", "SELECT TEMPORAL RANGE:",
choices=[ choices=[
"Last 365 days", "Last 365 days",
"Last 30 days", "Last 30 days",
"This Month", "This Month",
"This Year", "This Year",
"Custom range..." "Custom range..."
] ],
style=custom_style
).ask() ).ask()
end_date_dt = datetime.now() end_date_dt = datetime.now()
@@ -200,28 +201,24 @@ def calculate_checkins(client):
elif range_choice == "This Year": elif range_choice == "This Year":
start_date_dt = end_date_dt.replace(month=1, day=1) 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):", default=(datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d"), style=custom_style).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):", default=datetime.now().strftime("%Y-%m-%d"), style=custom_style).ask()
try: try:
start_date_dt = datetime.strptime(start_str, "%Y-%m-%d") start_date_dt = datetime.strptime(start_str, "%Y-%m-%d")
end_date_dt = datetime.strptime(end_str, "%Y-%m-%d") end_date_dt = datetime.strptime(end_str, "%Y-%m-%d")
if start_date_dt > end_date_dt: if start_date_dt > end_date_dt:
console.print("[red]Start date must be before end date![/red]") console.print("[error]ERROR: INVALID CHRONOLOGY. START MUST PRECEDE END.[/error]")
continue continue
break break
except ValueError: except ValueError:
console.print("[red]Invalid format. Please use YYYY-MM-DD.[/red]") console.print("[error]ERROR: INVALID DATA FORMAT. USE YYYY-MM-DD.[/error]")
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')}..."): with console.status(f"[highlight]INITIALIZING DATA RETRIEVAL...[/highlight]"):
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_start = start_date_dt.date()
requested_end = end_date_dt.date() requested_end = end_date_dt.date()
@@ -236,10 +233,7 @@ def calculate_checkins(client):
date_str = dt.strftime("%Y-%m-%d") date_str = dt.strftime("%Y-%m-%d")
is_absent = item.get("IsAbsent", False) is_absent = item.get("IsAbsent", False)
hours = (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 = parse_tamigo_date(item.get("StartTime"))
@@ -248,91 +242,80 @@ def calculate_checkins(client):
hours = (et - st).total_seconds() / 3600.0 hours = (et - st).total_seconds() / 3600.0
has_actual = False has_actual = False
if hours > 0 or item.get("CheckInTime") or item.get("ActualStartTime"): if hours > 0 or item.get("CheckInTime") or item.get("ActualStartTime") or (item.get("StartTime") and not is_absent):
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 has_actual = True
if has_actual and not is_absent: if has_actual and not is_absent:
if date_str not in work_days: if date_str not in work_days:
work_days[date_str] = { work_days[date_str] = {"hours": float(hours), "text": item.get("ActualShiftText") or item.get("ActivityName") or "WORKED"}
"hours": float(hours),
"text": item.get("ActualShiftText") or item.get("ActivityName") or item.get("DepartmentName") or "Worked"
}
else: else:
work_days[date_str]["hours"] += float(hours) 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 FOR SPECIFIED PARAMETERS.[/warning]")
return return
all_dates = sorted(work_days.keys(), reverse=True) all_dates = sorted(work_days.keys(), reverse=True)
table = Table(title="Worked Days in Period", show_header=True, header_style="bold magenta") table = Table(title="LOG ENTRIES: ACTUAL WORKED SHIFTS", show_header=True, header_style="table.header", box=None)
table.add_column("Date", style="cyan") table.add_column("DATE", style="highlight")
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())
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')}\n"
console.print(f" - Period: [cyan]{start_date_dt.strftime('%Y-%m-%d')} to {end_date_dt.strftime('%Y-%m-%d')}[/cyan]") summary_text += f"DAYS WORKED: {len(all_dates)}\n"
console.print(f" - Days Worked: [bold green]{len(all_dates)}[/bold green]") summary_text += f"TOTAL HOURS: {sum(d['hours'] for d in work_days.values()):.2f}"
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]")
console.print(Panel(summary_text, title="SUMMARY STATISTICS", border_style="highlight", expand=False))
else:
console.print("[error]FATAL ERROR: DATA RETRIEVAL FAILURE.[/error]")
def show_profile(client): def show_profile(client):
if client.user_info: if client.user_info:
console.print_json(data=client.user_info) console.print_json(data=client.user_info)
else: else:
console.print("[yellow]No profile info available. Are you logged in?[/yellow]") console.print("[error]ERROR: ACCESS DENIED. SESSION TOKEN NOT FOUND.[/error]")
def main(): def main():
print_header()
client = TamigoClient() client = TamigoClient()
console.print("[bold blue]Welcome to Tamigo CLI[/bold blue]")
email = os.getenv("TAMIGO_EMAIL") email = os.getenv("TAMIGO_EMAIL")
if not email: if not email:
email = questionary.text("Email:").ask() email = questionary.text("ENTER IDENTIFIER (EMAIL):", style=custom_style).ask()
password = os.getenv("TAMIGO_PASSWORD") password = os.getenv("TAMIGO_PASSWORD")
if not password: if not password:
password = questionary.password("Password:").ask() password = questionary.password("ENTER ACCESS KEY:", style=custom_style).ask()
if not email or not password: if not email or not password:
return return
with console.status("[bold green]Logging in..."): with console.status("[highlight]AUTHENTICATING WITH VAULT-TEC CLOUD...[/highlight]"):
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. ACCESS GRANTED.[/success]")
menu(client) menu(client)
else: else:
console.print("[bold red]Login failed. Please check your credentials.[/bold red]") console.print("[error]CRITICAL FAILURE: INVALID CREDENTIALS.[/error]")
def menu(client): def menu(client):
while True: while True:
choice = questionary.select( choice = questionary.select(
"What would you like to do?", "SELECT ACTION:",
choices=[ choices=[
"Calculate actual work days", "Calculate actual work days",
"Show profile info", "Show profile info",
"Logout and Exit" "Logout and Exit"
] ],
style=custom_style
).ask() ).ask()
if choice == "Calculate actual work days": if choice == "Calculate actual work days":
@@ -340,6 +323,7 @@ def menu(client):
elif choice == "Show profile info": elif choice == "Show profile info":
show_profile(client) show_profile(client)
else: else:
console.print("[highlight]SYSTEM SHUTDOWN INITIATED...[/highlight]")
break break
if __name__ == "__main__": if __name__ == "__main__":