31 Commits

Author SHA1 Message Date
Daniel Dybing
b100533e03 Refactor: Move theme and display settings to a dedicated Settings menu
All checks were successful
Build Tamigo CLI / Build Linux Binary (pull_request) Successful in 53s
Build Tamigo CLI / Build Windows Binary (pull_request) Successful in 44s
2026-03-14 11:53:53 +01:00
Daniel Dybing
d0196fefd0 Fix issue1: Hide zero-hour days by default and add toggle option in menu 2026-03-14 11:52:31 +01:00
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
Daniel Dybing
5c818a09e0 Workflow: Synchronize release workflow with robust Windows build logic
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 49s
Build Tamigo CLI / Build Windows Binary (push) Successful in 41s
2026-03-11 21:00:19 +01:00
Daniel Dybing
19bed7d812 Workflow: Final robust fix for Windows builds using environment overrides
Some checks failed
Build Tamigo CLI / Build Windows Binary (push) Successful in 42s
Build Tamigo CLI / Build Linux Binary (push) Successful in 51s
Release Tamigo CLI / Build & Release Linux (release) Successful in 45s
Release Tamigo CLI / Build & Release Windows (release) Failing after 4s
2026-03-11 16:24:56 +01:00
Daniel Dybing
32da1e8ee1 Workflow: Use xvfb-run for headless Windows Python installation in Docker
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 52s
Build Tamigo CLI / Build Windows Binary (push) Failing after 2m44s
2026-03-11 16:14:08 +01:00
Daniel Dybing
30d4c9dd47 Workflow: Use True Windows Python inside Wine to generate actual EXE
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 52s
Build Tamigo CLI / Build Windows Binary (push) Failing after 2m24s
2026-03-11 16:06:28 +01:00
Daniel Dybing
6685db8063 Workflow: Add binutils to local Windows builder to provide objdump
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 51s
Build Tamigo CLI / Build Windows Binary (push) Successful in 3m4s
2026-03-11 16:00:31 +01:00
Daniel Dybing
fd8f7a13d5 Workflow: Fix shell variable expansion for Windows container naming
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 52s
Build Tamigo CLI / Build Windows Binary (push) Failing after 7s
2026-03-11 15:54:34 +01:00
Daniel Dybing
eb845563a7 Workflow: Build local Docker environment for Windows to guarantee availability
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 54s
Build Tamigo CLI / Build Windows Binary (push) Failing after 2m43s
2026-03-11 15:41:42 +01:00
Daniel Dybing
c0b37f456e Workflow: Use skandyla/wine-pyinstaller and docker cp for robust Windows builds
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 49s
Build Tamigo CLI / Build Windows Binary (push) Failing after 4s
2026-03-11 15:38:22 +01:00
Daniel Dybing
02d4e015c7 Workflow: Revert to standard CDRX entrypoint for Windows build
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 52s
Build Tamigo CLI / Build Windows Binary (push) Failing after 5s
2026-03-11 15:30:48 +01:00
Daniel Dybing
3eb2b8319d Workflow: Use manual docker build/cp for Windows to ensure reliability
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 51s
Build Tamigo CLI / Build Windows Binary (push) Successful in 15s
2026-03-11 15:26:04 +01:00
Daniel Dybing
8d2ac1c4e4 Workflow: Use docker cp for robust Windows binary extraction
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 52s
Build Tamigo CLI / Build Windows Binary (push) Successful in 15s
2026-03-11 15:23:08 +01:00
Daniel Dybing
797a9f7535 Workflow: Simplify Windows build output and use broader artifact search
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 48s
Build Tamigo CLI / Build Windows Binary (push) Successful in 4s
2026-03-11 15:19:59 +01:00
Daniel Dybing
8e4ae990f9 Workflow: Streamline Windows build using /tmp for PyInstaller to fix path errors
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 52s
Build Tamigo CLI / Build Windows Binary (push) Successful in 4s
2026-03-11 15:15:02 +01:00
Daniel Dybing
fd4889da1e Workflow: Fix Windows build WinError 123 by upgrading PyInstaller and using explicit paths
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 55s
Build Tamigo CLI / Build Windows Binary (push) Failing after 4s
2026-03-11 15:12:12 +01:00
Daniel Dybing
9c1fff82d9 Workflow: Use discovery mode to find Windows binary inside Docker
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 52s
Build Tamigo CLI / Build Windows Binary (push) Failing after 15s
2026-03-11 15:01:32 +01:00
Daniel Dybing
83edd9c2da Workflow: Use recursive glob to capture Windows artifact
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 51s
Build Tamigo CLI / Build Windows Binary (push) Successful in 5s
2026-03-11 14:58:54 +01:00
Daniel Dybing
f30f06ea13 Workflow: Simplify Windows build and fix artifact path
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 51s
Build Tamigo CLI / Build Windows Binary (push) Successful in 5s
2026-03-11 14:53:39 +01:00
Daniel Dybing
ffcca538bf Workflow: Switch to burningtyger Docker image to fix Windows path errors
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 50s
Build Tamigo CLI / Build Windows Binary (push) Failing after 5s
2026-03-11 14:50:49 +01:00
Daniel Dybing
4577d9f6b9 Workflow: Use unique container names to avoid build conflicts
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 49s
Build Tamigo CLI / Build Windows Binary (push) Failing after 16s
2026-03-11 14:22:21 +01:00
Daniel Dybing
334ceae3ea Workflow: Simplify Windows Docker build by using image defaults
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 50s
Build Tamigo CLI / Build Windows Binary (push) Failing after 3s
2026-03-11 14:13:03 +01:00
Daniel Dybing
b156f203ba Workflow: Switch to modern Python 3.12 Docker image for Windows builds
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 51s
Build Tamigo CLI / Build Windows Binary (push) Failing after 4s
2026-03-11 14:07:46 +01:00
Daniel Dybing
411a2ae164 Workflow: Use robust docker cp method for Windows builds to fix volume mount issues
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 50s
Build Tamigo CLI / Build Windows Binary (push) Failing after 15s
2026-03-11 13:56:11 +01:00
Daniel Dybing
2d910949ad Workflow: Fix Windows artifact detection with wildcards and debug listing
Some checks failed
Build Tamigo CLI / Build Linux Binary (push) Successful in 52s
Build Tamigo CLI / Build Windows Binary (push) Failing after 4s
2026-03-11 13:51:25 +01:00
Daniel Dybing
6ecc2b4946 Workflow: Improve job naming and artifact separation
All checks were successful
Build Tamigo CLI / Build Linux Binary (push) Successful in 48s
Build Tamigo CLI / Build Windows Binary (push) Successful in 5s
2026-03-11 12:40:36 +01:00
Daniel Dybing
cec04a1cda Workflow: Fix release attachments and distinct artifact naming
All checks were successful
Build Tamigo CLI / build-linux (push) Successful in 51s
Build Tamigo CLI / build-windows (push) Successful in 4s
2026-03-11 12:34:29 +01:00
11 changed files with 547 additions and 296 deletions

View File

@@ -9,44 +9,70 @@ on:
jobs: jobs:
build-linux: build-linux:
name: Build Linux Binary
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
- name: Build
- name: Build with PyInstaller run: pyinstaller --onefile --name tamigo-cli tamigo.py
run: | - name: Upload Linux Artifact
pyinstaller --onefile --name tamigo-cli tamigo.py
- name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: tamigo-cli-linux name: tamigo-cli-linux-binary
path: dist/tamigo-cli path: dist/tamigo-cli
build-windows: build-windows:
name: Build Windows Binary
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build Windows executable via Docker - name: Build Windows executable (Robust CP Method)
run: | run: |
docker run --rm \ CONTAINER_NAME="win-builder-${{ github.run_id }}"
-v "${{ github.workspace }}:/src" \
cdrx/pyinstaller-windows \
sh -c "pip install -r requirements.txt && pyinstaller --onefile --name tamigo-cli tamigo.py"
- name: Upload artifact # 1. Create and start container in background
docker run -d --name $CONTAINER_NAME --entrypoint tail cdrx/pyinstaller-windows -f /dev/null
# 2. Copy source into container
docker exec $CONTAINER_NAME mkdir -p /src
docker cp . $CONTAINER_NAME:/src
# 3. Run build with explicit environment variables to fix the /tmp bug
# We also use explicit paths for every PyInstaller directory
docker exec -w /src $CONTAINER_NAME sh -c "
export TMP=C:\\\\temp && \
export TEMP=C:\\\\temp && \
mkdir -p /src/build /src/dist_win && \
pip install -r requirements.txt && \
pyinstaller --onefile --name tamigo-cli \
--workpath /src/build \
--distpath /src/dist_win \
--specpath /src/build \
tamigo.py"
# 4. Extract results
mkdir -p win_dist
docker cp $CONTAINER_NAME:/src/dist_win/tamigo-cli.exe ./win_dist/tamigo-cli.exe
# 5. Cleanup
docker rm -f $CONTAINER_NAME
- name: Debug - List Files
if: always()
run: |
ls -R win_dist/ || echo "win_dist not found"
- name: Upload Windows Artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: tamigo-cli-windows name: tamigo-cli-windows-binary
path: dist/tamigo-cli.exe path: win_dist/tamigo-cli.exe

View File

@@ -5,7 +5,8 @@ on:
types: [published] types: [published]
jobs: jobs:
build-linux: release-linux:
name: Build & Release Linux
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -17,26 +18,55 @@ jobs:
run: | run: |
pip install -r requirements.txt pip install -r requirements.txt
- name: Build - name: Build
run: | run: pyinstaller --onefile --name tamigo-cli tamigo.py
pyinstaller --onefile --name tamigo-cli tamigo.py - name: Rename for release
- name: Upload artifact run: mv dist/tamigo-cli dist/tamigo-cli-linux
uses: actions/upload-artifact@v3 - name: Upload to Release
uses: softprops/action-gh-release@v2
with: with:
name: tamigo-cli-linux files: dist/tamigo-cli-linux
path: dist/tamigo-cli env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-windows: release-windows:
name: Build & Release Windows
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build Windows executable via Docker
- name: Build Windows executable (Robust Release Fix)
run: | run: |
docker run --rm \ CONTAINER_NAME="win-rel-builder-${{ github.run_id }}"
-v "${{ github.workspace }}:/src" \
cdrx/pyinstaller-windows \ # 1. Create and start container in background
sh -c "pip install -r requirements.txt && pyinstaller --onefile --name tamigo-cli tamigo.py" docker run -d --name $CONTAINER_NAME --entrypoint tail cdrx/pyinstaller-windows -f /dev/null
- name: Upload artifact
uses: actions/upload-artifact@v3 # 2. Copy source into container
docker exec $CONTAINER_NAME mkdir -p /src
docker cp . $CONTAINER_NAME:/src
# 3. Run build with explicit environment variables to fix the /tmp bug
docker exec -w /src $CONTAINER_NAME sh -c "
export TMP=C:\\\\temp && \
export TEMP=C:\\\\temp && \
mkdir -p /src/build /src/dist_win && \
pip install -r requirements.txt && \
pyinstaller --onefile --name tamigo-cli \
--workpath /src/build \
--distpath /src/dist_win \
--specpath /src/build \
tamigo.py"
# 4. Extract results
mkdir -p win_dist
docker cp $CONTAINER_NAME:/src/dist_win/tamigo-cli.exe ./win_dist/tamigo-cli-windows.exe
# 5. Cleanup
docker rm -f $CONTAINER_NAME
- name: Upload to Release
uses: softprops/action-gh-release@v2
with: with:
name: tamigo-cli-windows files: win_dist/tamigo-cli-windows.exe
path: dist/tamigo-cli.exe env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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

8
GEMINI.md Normal file
View File

@@ -0,0 +1,8 @@
## Windows Cross-Compilation Fix (Gitea Actions)
- **Problem**: The Windows build was failing due to `OSError: [WinError 123] Invalid name: '/tmp\\*'` and volume mount issues in the Gitea CI environment.
- **Solution**:
1. Used the `cdrx/pyinstaller-windows` Docker image but bypassed its default entrypoint.
2. Implemented a "Manual Copy" strategy using `docker cp` to move files in and out of the container, avoiding unreliable volume mounts.
3. Forced PyInstaller to use Windows-compatible paths by explicitly setting `TMP` and `TEMP` environment variables to `C:\\temp` and specifying `--workpath` and `--distpath` during the build command.
4. Downgraded `upload-artifact` to `v3` for compatibility with Gitea's artifact storage.

View File

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

518
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,112 @@ 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:
config = json.load(f)
if "show_zero_hours" not in config:
config["show_zero_hours"] = False
return config
except:
pass
return {"theme": "fallout", "show_zero_hours": False} # Default to fallout and hide zero hours
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,303 +128,235 @@ 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: if response.status_code == 200:
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
self.employee_id = data.get("EmployeeId")
return True return True
except json.JSONDecodeError: except: continue
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 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):
""" if not self.session_token: return None
Fetches actual worked shifts using employee-accessible endpoints. headers = {"x-tamigo-token": self.session_token, "securitytoken": self.session_token, "Accept": "application/json"}
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 = [] 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")
if (end_date_dt - timedelta(days=i)) < start_date_dt - timedelta(days=60): break
# 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: try:
response = requests.get(url, headers=headers) response = requests.get(f"{BASE_URL}/actualshifts/past/{target_date}", headers=headers)
if response.status_code == 200: if response.status_code == 200: all_shifts.extend(response.json())
data = response.json()
if isinstance(data, list):
all_shifts.extend(data)
elif response.status_code == 401: elif response.status_code == 401:
response = requests.get(url, params={"securitytoken": self.session_token}) resp = requests.get(f"{BASE_URL}/actualshifts/past/{target_date}", params={"securitytoken": self.session_token})
if response.status_code == 200: if resp.status_code == 200: all_shifts.extend(resp.json())
all_shifts.extend(response.json()) except: pass
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: try:
response = requests.get(url_period, headers=headers) 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: if response.status_code == 200: all_shifts.extend(response.json())
data = response.json() except: pass
if isinstance(data, list):
all_shifts.extend(data)
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)
show_zero = current_config.get("show_zero_hours", False)
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():
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 dt: # Filter zero hours if requested
item_date = dt.date() if hours == 0 and not show_zero:
if not (requested_start <= item_date <= requested_end):
continue continue
date_str = dt.strftime("%Y-%m-%d") if (hours > 0 or item.get("CheckInTime") or item.get("ActualStartTime") or item.get("StartTime")) and not item.get("IsAbsent", False):
is_absent = 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
hours = (item.get("ActualShiftHours") or def calculate_checkins(client):
item.get("CheckInOutShiftHours") or start_date_dt, end_date_dt = select_date_range()
item.get("Hours") or 0) work_days = get_worked_days(client, start_date_dt, end_date_dt)
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 work_days is not None:
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)
def show_profile(client): if work_days is not None:
if client.user_info: if not work_days:
console.print_json(data=client.user_info) console.print("[warning]WARNING: NO LOG ENTRIES DETECTED.[/warning]")
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 return
with console.status("[bold green]Logging in..."): format_choice = questionary.select(
success = client.login(email, password) "SELECT EXPORT FORMAT:" if current_theme_name == "fallout" else "Select export format:",
choices=["CSV", "JSON", "XLSX"],
style=theme_data["questionary"]
).ask()
if success: default_filename = f"tamigo_export_{start_date_dt.strftime('%Y%m%d')}_{end_date_dt.strftime('%Y%m%d')}"
console.print("[bold green]Login successful![/bold green]") filename = questionary.text(
menu(client) "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: else:
console.print("[bold red]Login failed. Please check your credentials.[/bold red]") 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): def menu(client):
while True: while True:
settings_text = "SETTINGS" if current_theme_name == "fallout" else "Settings"
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", settings_text, "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 == settings_text:
settings_menu()
else:
console.print("[highlight]SYSTEM SHUTDOWN...[/highlight]" if current_theme_name == "fallout" else "Exiting...")
break
def settings_menu():
while True:
show_zero = current_config.get("show_zero_hours", False)
toggle_text = "HIDE ZERO-HOUR DAYS" if current_theme_name == "fallout" else "Hide zero-hour days"
if not show_zero:
toggle_text = "SHOW ZERO-HOUR DAYS" if current_theme_name == "fallout" else "Show zero-hour days"
back_text = "BACK" if current_theme_name == "fallout" else "Back"
choice = questionary.select(
"SELECT SETTING:" if current_theme_name == "fallout" else "Settings:",
choices=["Switch UI Theme", toggle_text, back_text],
style=theme_data["questionary"]
).ask()
if 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]")
elif choice == toggle_text:
current_config["show_zero_hours"] = not show_zero
save_config(current_config)
state = "VISIBLE" if not show_zero else "HIDDEN"
console.print(f"[success]ZERO-HOUR DAYS ARE NOW {state}.[/success]" if current_theme_name == "fallout" else f"[success]Zero-hour days are now {state.lower()}.[/success]")
else: else:
break break

158
test_export.py Normal file
View File

@@ -0,0 +1,158 @@
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)
def test_get_worked_days_zero_hour_filtering(self):
# Mock API response with a 0-hour shift (missing EndTime so it stays 0)
mock_shifts = [
{
"Date": "/Date(1741564800000)/", # 2025-03-10
"StartTime": "2025-03-10T09:00:00Z",
"ActualShiftHours": 0,
"ActualShiftText": "Sick Day",
"IsAbsent": False
}
]
self.client.get_employee_actual_shifts.return_value = mock_shifts
start_date = datetime(2025, 3, 1)
end_date = datetime(2025, 3, 15)
# 1. Test with show_zero_hours = False (Default)
with patch('tamigo.console'), patch('tamigo.current_config', {'show_zero_hours': False}):
work_days = get_worked_days(self.client, start_date, end_date)
self.assertEqual(len(work_days), 0)
# 2. Test with show_zero_hours = True
with patch('tamigo.console'), patch('tamigo.current_config', {'show_zero_hours': True}):
work_days = get_worked_days(self.client, start_date, end_date)
self.assertEqual(len(work_days), 1)
self.assertIn("2025-03-10", work_days)
self.assertEqual(work_days["2025-03-10"]["hours"], 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()