Created
May 21, 2025 19:22
-
-
Save gcleaves/d837af20624c75c6d677dfb399d794d2 to your computer and use it in GitHub Desktop.
Automated timesheet entry script for BambooHR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
timesheet.py - Automated timesheet entry script for BambooHR | |
This script automates the process of submitting timesheet entries to BambooHR. | |
It allows users to submit multiple workdays of time entries with customizable | |
time slots for weekdays and Fridays. | |
Getting an API key: https://documentation.bamboohr.com/docs/getting-started#authentication | |
Features: | |
- Automatically skips weekends | |
- Customizable time slots for weekdays and Fridays | |
- Default schedule: | |
- Weekdays (Mon-Thu): 8:30-13:15 and 14:15-17:30 | |
- Fridays: 8:30-13:15 and 14:15-16:00 | |
- Validates date format and number of days | |
- Provides a dry-run preview before submission | |
- Uses BambooHR API for submission | |
Requirements: | |
- Python 3.6+ | |
- Valid BambooHR API credentials | |
Configuration: | |
- BAMBOOHR_APIKEY: Your BambooHR API key (environment variable) | |
- BAMBOOHR_EMPID: Your employee ID (environment variable) | |
""" | |
import os | |
import sys | |
import json | |
import base64 | |
import argparse | |
from datetime import datetime, timedelta | |
from typing import List, Dict, Any, Optional, NamedTuple | |
from urllib import request, parse, error | |
from getpass import getpass | |
class TimeSlot(NamedTuple): | |
start: str | |
end: str | |
class TimeConfig: | |
def __init__( | |
self, | |
weekday_morning: TimeSlot = TimeSlot("08:30", "13:15"), | |
weekday_afternoon: TimeSlot = TimeSlot("14:15", "17:30"), | |
friday_morning: TimeSlot = TimeSlot("08:30", "13:15"), | |
friday_afternoon: TimeSlot = TimeSlot("14:15", "16:00") | |
): | |
self.weekday_morning = weekday_morning | |
self.weekday_afternoon = weekday_afternoon | |
self.friday_morning = friday_morning | |
self.friday_afternoon = friday_afternoon | |
@staticmethod | |
def validate_time(time_str: str) -> bool: | |
try: | |
hour, minute = map(int, time_str.split(':')) | |
return 0 <= hour <= 23 and 0 <= minute <= 59 | |
except (ValueError, TypeError): | |
return False | |
@classmethod | |
def from_user_input(cls) -> 'TimeConfig': | |
print("\nConfigure time slots (press Enter to use defaults):") | |
def get_time_slot(prompt: str, default: TimeSlot) -> TimeSlot: | |
while True: | |
time_input = input(f"{prompt} [{default.start}-{default.end}]: ").strip() | |
if not time_input: | |
return default | |
try: | |
start, end = time_input.split('-') | |
start = start.strip() | |
end = end.strip() | |
if not (cls.validate_time(start) and cls.validate_time(end)): | |
print("Invalid time format. Please use HH:MM format (e.g., 08:30-13:15)") | |
continue | |
return TimeSlot(start, end) | |
except ValueError: | |
print("Invalid format. Please use START-END format (e.g., 08:30-13:15)") | |
weekday_morning = get_time_slot( | |
"Weekday morning slot (Mon-Thu)", | |
TimeSlot("08:30", "13:15") | |
) | |
weekday_afternoon = get_time_slot( | |
"Weekday afternoon slot (Mon-Thu)", | |
TimeSlot("14:15", "17:30") | |
) | |
friday_morning = get_time_slot( | |
"Friday morning slot", | |
TimeSlot("08:30", "13:15") | |
) | |
friday_afternoon = get_time_slot( | |
"Friday afternoon slot", | |
TimeSlot("14:15", "16:00") | |
) | |
return cls( | |
weekday_morning=weekday_morning, | |
weekday_afternoon=weekday_afternoon, | |
friday_morning=friday_morning, | |
friday_afternoon=friday_afternoon | |
) | |
class TimesheetEntry: | |
def __init__(self, employee_id: int, date: str, start: str, end: str): | |
self.employee_id = employee_id | |
self.date = date | |
self.start = start | |
self.end = end | |
def to_dict(self) -> Dict[str, Any]: | |
return { | |
"employeeId": self.employee_id, | |
"date": self.date, | |
"start": self.start, | |
"end": self.end | |
} | |
class BambooHRTimesheet: | |
def __init__( | |
self, | |
api_key: Optional[str] = None, | |
employee_id: Optional[str] = None, | |
time_config: Optional[TimeConfig] = None | |
): | |
self.api_key = api_key or os.getenv("BAMBOOHR_APIKEY") | |
self.employee_id = employee_id or os.getenv("BAMBOOHR_EMPID") | |
self.base_url = "https://api.bamboohr.com/api/gateway.php/redpoints/v1" | |
self.time_config = time_config or TimeConfig() | |
if not self.api_key: | |
self.api_key = getpass("Enter your BambooHR API key: ") | |
if not self.api_key: | |
raise ValueError("API key is required") | |
if not self.employee_id: | |
self.employee_id = input("Enter your BambooHR employee ID: ") | |
if not self.employee_id.isdigit(): | |
raise ValueError("Employee ID must be a number") | |
self.employee_id = int(self.employee_id) | |
def get_entries(self, start_date: str, num_days: int) -> List[TimesheetEntry]: | |
entries: List[TimesheetEntry] = [] | |
current_date = datetime.strptime(start_date, "%Y-%m-%d") | |
days_added = 0 | |
while days_added < num_days: | |
# Skip weekends (5 = Saturday, 6 = Sunday) | |
if current_date.weekday() < 5: | |
is_friday = current_date.weekday() == 4 | |
# Get appropriate time slots based on day | |
morning_slot = self.time_config.friday_morning if is_friday else self.time_config.weekday_morning | |
afternoon_slot = self.time_config.friday_afternoon if is_friday else self.time_config.weekday_afternoon | |
# Add morning entry | |
entries.append(TimesheetEntry( | |
self.employee_id, | |
current_date.strftime("%Y-%m-%d"), | |
morning_slot.start, | |
morning_slot.end | |
)) | |
# Add afternoon entry | |
entries.append(TimesheetEntry( | |
self.employee_id, | |
current_date.strftime("%Y-%m-%d"), | |
afternoon_slot.start, | |
afternoon_slot.end | |
)) | |
days_added += 1 | |
current_date += timedelta(days=1) | |
return entries | |
def submit_entries(self, entries: List[TimesheetEntry]) -> None: | |
payload = { | |
"entries": [entry.to_dict() for entry in entries] | |
} | |
# Preview the payload | |
print("\n๐ Dry run preview:") | |
print("-" * 28) | |
print(json.dumps(payload, indent=2)) | |
print("-" * 28) | |
confirm = input("Do you want to submit this data? [y/N]: ").lower() | |
if confirm != 'y': | |
print("โ Submission canceled.") | |
return | |
print("๐ Sending data...") | |
# Prepare the request | |
url = f"{self.base_url}/time_tracking/clock_entries/store" | |
data = json.dumps(payload).encode('utf-8') | |
# Create request with authentication | |
auth_string = base64.b64encode(f"{self.api_key}:x".encode()).decode() | |
headers = { | |
'Authorization': f'Basic {auth_string}', | |
'Content-Type': 'application/json', | |
'Accept': 'application/json' | |
} | |
req = request.Request(url, data=data, headers=headers, method='POST') | |
try: | |
with request.urlopen(req) as response: | |
if response.status in (200, 201): | |
print("\nโ Submission complete.") | |
else: | |
print(f"\nโ Error: HTTP {response.status}") | |
print(f"Response: {response.read().decode()}") | |
sys.exit(1) | |
except error.HTTPError as e: | |
if e.code == 401: | |
print("\nโ Authentication failed. Please check your API key.") | |
elif e.code == 403: | |
print("\nโ Access forbidden. Please check your permissions.") | |
elif e.code == 400: | |
print("\nโ Bad request. Error details:") | |
print(e.read().decode()) | |
else: | |
print(f"\nโ Error: HTTP {e.code}") | |
print(f"Response: {e.read().decode()}") | |
sys.exit(1) | |
except error.URLError as e: | |
print(f"\nโ Network error: {str(e)}") | |
sys.exit(1) | |
def parse_args(): | |
parser = argparse.ArgumentParser( | |
description="Automated timesheet entry script for BambooHR", | |
formatter_class=argparse.RawDescriptionHelpFormatter, | |
epilog=__doc__ | |
) | |
parser.add_argument( | |
"--start-date", | |
help="Start date in YYYY-MM-DD format (default: today)", | |
type=str | |
) | |
parser.add_argument( | |
"--days", | |
help="Number of workdays to submit (default: 5)", | |
type=int | |
) | |
parser.add_argument( | |
"--api-key", | |
help="BambooHR API key (default: from BAMBOOHR_APIKEY env)", | |
type=str | |
) | |
parser.add_argument( | |
"--employee-id", | |
help="Employee ID (default: from BAMBOOHR_EMPID env)", | |
type=str | |
) | |
return parser.parse_args() | |
def main(): | |
try: | |
args = parse_args() | |
# Initialize the timesheet handler with custom time slots | |
time_config = TimeConfig.from_user_input() | |
timesheet = BambooHRTimesheet( | |
api_key=args.api_key, | |
employee_id=args.employee_id, | |
time_config=time_config | |
) | |
# Get start date (from args or prompt) | |
start_date = args.start_date | |
if not start_date: | |
start_date = input("Enter start date (YYYY-MM-DD) [default: today]: ").strip() | |
if not start_date: | |
start_date = datetime.now().strftime("%Y-%m-%d") | |
# Validate date format | |
try: | |
datetime.strptime(start_date, "%Y-%m-%d") | |
except ValueError: | |
print("Invalid date format. Please use YYYY-MM-DD.") | |
sys.exit(1) | |
# Get number of days (from args or prompt) | |
num_days = args.days | |
if num_days is None: | |
num_days_input = input("How many workdays to submit? [default: 5]: ").strip() | |
num_days = int(num_days_input) if num_days_input else 5 | |
if num_days < 1: | |
print("Please enter a valid number of days (1 or more).") | |
sys.exit(1) | |
# Generate and submit entries | |
entries = timesheet.get_entries(start_date, num_days) | |
timesheet.submit_entries(entries) | |
except KeyboardInterrupt: | |
print("\n\nScript interrupted by user.") | |
sys.exit(1) | |
except Exception as e: | |
print(f"\nโ Error: {str(e)}") | |
sys.exit(1) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
You must have python installed to use this.
Usage:
python timesheet.py
.python timesheet.py --help
for more info.