From 5b0cc57c8485cc9f97e1c53583bbac342f2bcbba7208bd700d79e8e0e38bfd85 Mon Sep 17 00:00:00 2001 From: JaniSoto Date: Tue, 7 Jul 2026 11:09:05 +0200 Subject: [PATCH] first commit --- .gitignore | 30 +++++ README.md | 70 +++++++++++ sync_hours.py | 312 ++++++++++++++++++++++++++++++++++++++++++++++++ work_times.xlsx | Bin 0 -> 10380 bytes 4 files changed, 412 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 sync_hours.py create mode 100644 work_times.xlsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..083305e --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Virtual environment +venv/ +.venv/ +env/ + +# Environment variables and secrets +.env +.env.* +!.env.example + +# Python byte-compiled / optimized / DLL files and caches +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ + +# Office temporary files (created automatically when opening Excel files) +~$*.xlsx +~$*.xls + +# --- Data files --- +# If your Excel sheet contains dynamic local data that you do not want in git, +# uncomment the line below. Otherwise, keep it commented to track the spreadsheet. +# work_times.xlsx + +# OS-specific files +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fa7c24 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# TimeRecording Sync & Balance Tracker + +Automates the extraction, processing, and formatting of work hours from the IDENTsmart TimeRecording API into a local Excel spreadsheet (`work_times.xlsx`). Designed for Verbundstudium (dual-study) contracts. + +## 1. Architecture & Execution Flow +1. **Authentication:** Spawns headless Playwright (Chromium) to execute Auth0 PKCE login and intercepts the `Bearer` token from network headers. Closes the browser immediately to prevent memory leaks. +2. **Data Fetching:** Calls API endpoints via `requests` to retrieve JSON payloads for raw physical clock events and official absences. +3. **Processing:** Maps clock events to academic phases, applying strict contractual targets and dynamic break deductions. +4. **Excel Generation:** Compiles data natively into `work_times.xlsx` via `openpyxl`, writing cross-platform Excel formulas for local calculation. + +## 2. Installation & Configuration + +**Prerequisites:** Python 3.9+ + +```bash +# 1. Initialize environment & dependencies +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install openpyxl requests playwright python-dotenv + +# 2. Install Playwright browser binary +playwright install chromium +``` + +**Environment Variables (`.env`)** +Create a `.env` file in the project root. All contractual parameters and credentials must reside here. + +```ini +TR_EMAIL=your_email@domain.com +TR_PASSWORD=your_secure_password +TR_START_DATE=2024-10-01 +SEMESTER_TARGET_TOTAL=240.0 +SEMESTER_WEEKS=39.0 +``` + +**Execution:** `python sync_hours.py` (overwrites `work_times.xlsx` on run). + +## 3. Domain Logic & Contract Rules + +The script strictly maps all days into two phases. Academic year resets annually on October 1st. + +| Phase | Dates | Daily Target | Absence Handling | +| :--- | :--- | :--- | :--- | +| **Fulltime** *(Vorlesungsfreie Zeit)* | Feb 15–Mar 14, Aug 1–Sep 30 | `08:00:00` | Sickness/Vacation overrides target to `00:00:00` (neutralizes deficit). | +| **Semester** *(Vorlesungszeit)* | All other dates | `00:00:00` | Ignored (target is already zero). | + +**Special Overrides:** +* **Berufsschule:** If logged during the **Semester** phase, script hardcodes clock-in to `08:00:00` and clock-out to `16:00:00` (8h net) to credit the rolling semester balance. + +## 4. Excel Mathematical Model + +Calculations are pushed as raw formulas so the resulting spreadsheet is fully dynamic. + +* **Gross Time:** `(Out1 - In1) + (Out2 - In2)`. Supports maximum 2 work blocks per day. +* **Pause (ArbZG Compliance):** Enforces German labor law (30m for $\ge$6h, 45m for $\ge$9h). To prevent double penalization, the script subtracts any *actual unlogged time* (gap between `Out1` and `In2`) from the statutory deduction. +* **Negative Time Workaround:** Standard Excel cannot display negative times without enabling the legacy 1904 Date System, which breaks cross-platform compatibility. The script uses a `TEXT()` formula evaluation to prefix a `"-"` string if `Worked < Target` (e.g., `"-0:35:00"`). +* **Overview Formatting:** Excel evaluates time internally as fractions of 1 day. `SEMESTER_TARGET_TOTAL` is divided by `24.0` during Python injection so Excel natively renders it as `240:00:00`. + +## 5. Maintenance & Troubleshooting + +| Component | Location / File | Action | +| :--- | :--- | :--- | +| **Contract Hours / Pace** | `.env` | Update `SEMESTER_TARGET_TOTAL` or `SEMESTER_WEEKS`. | +| **Academic Break Dates** | `Config.__init__` | Update `self.winter_break` and `self.summer_break` tuples. | +| **Query Range Start** | `.env` | Update `TR_START_DATE`. *Note: Older dates significantly increase API latency.* | +| **Excel Column Layout** | `ExcelWriter` | Added columns require manual shifting of formula column references (e.g., `G`, `H`). | + +**Known Issues & Fixes:** +* **Auth Timeouts / Failure:** TimeRecording uses Auth0. If their DOM layout changes, Playwright `.fill()` / `.click()` selectors will time out. Update CSS selectors in `TimeRecordingAPI.authenticate()`. +* **Zombie Chromium Processes:** If the script is force-killed (`SIGKILL`) before the `try...finally` block closes the browser, invisible Chromium instances may persist. Kill manually via `pkill Chrome` (Unix) or Task Manager (Windows). diff --git a/sync_hours.py b/sync_hours.py new file mode 100644 index 0000000..c69be9f --- /dev/null +++ b/sync_hours.py @@ -0,0 +1,312 @@ +import os +import requests +import zoneinfo +from dataclasses import dataclass +from datetime import datetime, date, timedelta +from dotenv import load_dotenv +from openpyxl import Workbook +from openpyxl.styles import PatternFill, Font, Alignment, Border, Side +from playwright.sync_api import sync_playwright + +@dataclass +class WorkDay: + date_obj: date + phase: str + in1: str = "" + out1: str = "" + in2: str = "" + out2: str = "" + note: str = "" + is_absence: bool = False + + @property + def target_time_formula(self) -> str: + # Zero target hours during semester or on pre-approved absence days. + if self.phase == "Semester": + return '=TIME(0,0,0)' + return '=TIME(0,0,0)' if self.is_absence else '=TIME(8,0,0)' + +class Config: + def __init__(self): + load_dotenv() + + self.email = os.environ.get("TR_EMAIL") + self.password = os.environ.get("TR_PASSWORD") + if not self.email or not self.password: + raise ValueError("Missing credentials in environment.") + + # Contractual constants + self.semester_target_total = float(os.environ.get("SEMESTER_TARGET_TOTAL", "240.0")) + self.semester_weeks = float(os.environ.get("SEMESTER_WEEKS", "39.0")) + + self.start_date = datetime.strptime(os.environ.get("TR_START_DATE", "2024-10-01"), "%Y-%m-%d").date() + self.local_tz = zoneinfo.ZoneInfo("Europe/Berlin") + + today = datetime.now().date() + self.end_date_str = today.strftime("%Y-%m-%d") + + # Academic year logic (resets Oct 1st annually) + if today.month >= 10: + self.acad_year_start = date(today.year, 10, 1) + self.current_term_end = date(today.year + 1, 7, 31) + else: + self.acad_year_start = date(today.year - 1, 10, 1) + self.current_term_end = date(today.year, 7, 31) + + days_left = (self.current_term_end - today).days + self.remaining_weeks = max(0.0, round(days_left / 7.0, 1)) + + # Semester breaks (Vorlesungsfreie Zeit) + self.winter_break = ((2, 15), (3, 14)) + self.summer_break = ((8, 1), (9, 30)) + +class TimeRecordingAPI: + """Handles Auth0 PKCE session hijacking and HTTP interactions with Time Recording API.""" + + def __init__(self, config: Config): + self.config = config + self.token = None + self.employee_id = None + self.base_url = "https://api.timerecording.com" + + def authenticate(self): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + try: + context = browser.new_context() + page = context.new_page() + + # Event listener to extract OAuth Bearer token from network headers + def capture_token(request): + if self.base_url in request.url: + auth = request.headers.get("authorization") + if auth and auth.startswith("Bearer "): + self.token = auth + + page.on("request", capture_token) + page.goto("https://app.timerecording.com/de/main/dashboard") + page.fill('input[name="username"], input[type="email"]', self.config.email) + page.fill('input[name="password"], input[type="password"]', self.config.password) + page.click('button[type="submit"], button[name="action"]') + + # Block until login request is executed + try: + with page.expect_request(lambda r: f"{self.base_url}/employees/me" in r.url, timeout=10000): + pass + except Exception: + pass + + if not self.token: + raise RuntimeError("Authentication failed: Could not capture Bearer token.") + + resp = context.request.get(f"{self.base_url}/employees/me", headers={"Authorization": self.token}) + if not resp.ok: + raise RuntimeError(f"Failed to fetch employee ID. HTTP {resp.status}") + + self.employee_id = resp.json().get("id") + finally: + browser.close() + + def fetch_data(self) -> tuple[dict, list]: + headers = {"authorization": self.token, "referer": "https://app.timerecording.com/"} + + r_url = f"{self.base_url}/employees/{self.employee_id}/record-buckets/{self.config.start_date}/{self.config.end_date_str}" + a_url = f"{self.base_url}/employees/{self.employee_id}/absences/{self.config.start_date}/{self.config.end_date_str}" + + r_resp = requests.get(r_url, headers=headers) + a_resp = requests.get(a_url, headers=headers) + + r_resp.raise_for_status() + a_resp.raise_for_status() + + return r_resp.json(), a_resp.json() + +class TimesheetProcessor: + """Merges physical clock events and official absences into chronologically mapped workdays.""" + + def __init__(self, config: Config): + self.config = config + + def get_phase(self, d: date) -> str: + w_start, w_end = date(d.year, *self.config.winter_break[0]), date(d.year, *self.config.winter_break[1]) + s_start, s_end = date(d.year, *self.config.summer_break[0]), date(d.year, *self.config.summer_break[1]) + + if (w_start <= d <= w_end) or (s_start <= d <= s_end): + return "Fulltime" + return "Semester" + + def process(self, records_data: dict, absences_data: list) -> list[WorkDay]: + days_map = {} + + # 1. Parse official absences (sick leave, public holidays, vocational school) + for item in absences_data: + name = item.get("absenceType", {}).get("name", item.get("name", "Absence")) + start_d = datetime.strptime(item["start"], "%Y-%m-%d").date() + end_d = datetime.strptime(item["end"], "%Y-%m-%d").date() + + curr = start_d + while curr <= end_d: + if curr >= self.config.start_date: + phase = self.get_phase(curr) + d_str = curr.strftime("%Y-%m-%d") + + # Hardcode vocational school times (Berufsschule) to match local regional guidelines + if name == "Berufsschule" and phase == "Semester": + days_map[d_str] = WorkDay(curr, phase, in1="08:00:00", out1="16:00:00", note="Berufsschule") + elif phase == "Fulltime" and name != "Berufsschule": + days_map[d_str] = WorkDay(curr, phase, note=name, is_absence=True) + curr += timedelta(days=1) + + # 2. Parse physical clock-in/out stamps + for d_str, records in records_data.items(): + curr_date = datetime.strptime(d_str, "%Y-%m-%d").date() + if curr_date < self.config.start_date: + continue + + if d_str not in days_map: + days_map[d_str] = WorkDay(curr_date, self.get_phase(curr_date)) + + # Skip merging logged times if day is overwritten by vocational school + if days_map[d_str].note == "Berufsschule": + continue + + records.sort(key=lambda x: x['time']) + ins = [r for r in records if r['type'] == 'work_start'] + outs = [r for r in records if r['type'] == 'work_stop'] + + def format_time(record): + dt = datetime.fromisoformat(record['time'].replace('Z', '+00:00')).astimezone(self.config.local_tz) + return dt.strftime('%H:%M:%S') + + # Map up to 2 shifts per calendar day + if len(ins) > 0: days_map[d_str].in1 = format_time(ins[0]) + if len(outs) > 0: days_map[d_str].out1 = format_time(outs[0]) + if len(ins) > 1: days_map[d_str].in2 = format_time(ins[1]) + if len(outs) > 1: days_map[d_str].out2 = format_time(outs[1]) + + return [days_map[k] for k in sorted(days_map.keys())] + +class ExcelWriter: + """Generates styled worksheet with dynamic formulas for balances and German break laws.""" + + def __init__(self, config: Config): + self.config = config + self.wb = Workbook() + + def save(self, workdays: list[WorkDay], filename="work_times.xlsx"): + ws = self.wb.active + ws.title = "Work Hours" + + headers = ["Date", "Phase", "In1", "Out1", "In2", "Out2", "Gross Time", + "Pause", "Net Time", "Target Time", "Semester Balance", + "Fulltime Balance", "Notes"] + ws.append(headers) + + thin_border = Border(left=Side(style='thin', color='D9D9D9'), right=Side(style='thin', color='D9D9D9'), + top=Side(style='thin', color='D9D9D9'), bottom=Side(style='thin', color='D9D9D9')) + + styles = { + "Semester": {"fill": PatternFill("solid", fgColor="E2EFDA"), "font": Font(name="Calibri", size=11, color="375623")}, + "Fulltime": {"fill": PatternFill("solid", fgColor="FFF2CC"), "font": Font(name="Calibri", size=11, color="7F6000")} + } + + for i, wd in enumerate(workdays, start=2): + gross_f = f"=(IF(D{i}>C{i},D{i}-C{i},0))+(IF(F{i}>E{i},F{i}-E{i},0))" + + # German statutory breaks (ArbZG): 30m after 6h total, 45m after 9h. Deduct manual gap if exists. + pause_f = (f'=MAX(0, IF(M{i}="Berufsschule", TIME(0,0,0), IF(G{i}>=TIME(9,0,0), TIME(0,45,0), ' + f'IF(G{i}>=TIME(6,0,0), TIME(0,30,0), TIME(0,0,0)))) - IF(AND(E{i}>0, D{i}>0), E{i}-D{i}, TIME(0,0,0)))') + + net_f = f"=IF(G{i}>0, G{i}-H{i}, 0)" + sem_bal_f = f'=SUMIF($B$2:B{i}, "Semester", $I$2:I{i})' + + # Workaround for Excel's 1900 date system which errors out on negative times + val_expr = f'ROUND(SUMIF($B$2:B{i}, "Fulltime", $I$2:I{i}) - SUMIF($B$2:B{i}, "Fulltime", $J$2:J{i}), 10)' + full_bal_f = f'=IF({val_expr}<0, "-" & TEXT(ABS({val_expr}), "[h]:mm:ss"), TEXT({val_expr}, "[h]:mm:ss"))' + + row_data = [wd.date_obj, wd.phase, wd.in1 or None, wd.out1 or None, wd.in2 or None, wd.out2 or None, + gross_f, pause_f, net_f, wd.target_time_formula, sem_bal_f, full_bal_f, wd.note or None] + + for col_idx, value in enumerate(row_data, start=1): + c = ws.cell(row=i, column=col_idx, value=value) + c.border = thin_border + c.alignment = Alignment(horizontal='left' if col_idx == 13 else 'center', vertical='center') + + ws.cell(row=i, column=1).number_format = 'yyyy-mm-dd' + for col in range(3, 12): + ws.cell(row=i, column=col).number_format = '[h]:mm:ss' + ws.cell(row=i, column=12).number_format = '@' + + p_cell = ws.cell(row=i, column=2) + p_cell.fill, p_cell.font = styles[wd.phase]["fill"], styles[wd.phase]["font"] + + # Style headers + for col_idx in range(1, 14): + c = ws.cell(row=1, column=col_idx) + c.fill = PatternFill("solid", fgColor="1F497D") + c.font = Font(name="Calibri", size=11, color="FFFFFF", bold=True) + c.alignment, c.border = Alignment(horizontal='center', vertical='center'), thin_border + + col_widths = {"A": 14, "B": 14, "C": 11, "D": 11, "E": 11, "F": 11, "G": 14, "H": 11, "I": 12, "J": 14, "K": 18, "L": 18, "M": 20} + for col_letter, width in col_widths.items(): + ws.column_dimensions[col_letter].width = width + + # Overview Sheet Calculations + ov = self.wb.create_sheet(title="Overview") + last_r = max(2, len(workdays) + 1) + + ov.merge_cells("A1:B1") + ov["A1"] = "Semester Target & Pace" + ov["A1"].fill = PatternFill("solid", fgColor="1F497D") + ov["A1"].font = Font(name="Calibri", size=11, color="FFFFFF", bold=True) + ov["A1"].alignment = Alignment(horizontal='center') + + wks = self.config.semester_weeks + ay_start = self.config.acad_year_start + + metrics = [ + ("Target Hours (Total)", self.config.semester_target_total / 24.0, '[h]:mm:ss'), + ("Worked Hours", f'=SUMIFS(\'Work Hours\'!I2:I{last_r}, \'Work Hours\'!B2:B{last_r}, "Semester", \'Work Hours\'!A2:A{last_r}, ">="&DATE({ay_start.year},{ay_start.month},{ay_start.day}))', '[h]:mm:ss'), + ("Remaining Hours", "=B2-B3", '[h]:mm:ss'), + ("Remaining Weeks", self.config.remaining_weeks, '0.0'), + ("Required Pace / Wk", "=IF(B5>0, B4/B5, 0)", '[h]:mm:ss'), + ("Current Deficit", f'=IF(B5>0, ((({wks}-B5)*(B2/{wks})) - B3), 0)', '[h]:mm:ss') + ] + + for idx, (label, val, fmt) in enumerate(metrics, start=2): + ov[f"A{idx}"] = label + ov[f"B{idx}"] = val + ov[f"A{idx}"].font = Font(name="Calibri", size=10, bold=True) + ov[f"B{idx}"].font = Font(name="Calibri", size=10) + ov[f"B{idx}"].number_format = fmt + + for c in "AB": + cell = ov[f"{c}{idx}"] + cell.border = thin_border + cell.alignment = Alignment(horizontal='left' if c == "A" else 'center') + + ov.column_dimensions["A"].width, ov.column_dimensions["B"].width = 24, 14 + + self.wb.save(filename) + print(f"Data saved to {filename}") + +def main(): + try: + config = Config() + + api = TimeRecordingAPI(config) + api.authenticate() + records, absences = api.fetch_data() + + processor = TimesheetProcessor(config) + workdays = processor.process(records, absences) + + writer = ExcelWriter(config) + writer.save(workdays) + + except Exception as e: + print(f"Process failed: {e}") + exit(1) + +if __name__ == "__main__": + main() diff --git a/work_times.xlsx b/work_times.xlsx new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..58ad6e01d2045a06efc033a50dcbccf0f9c8d535346384ca8f9c5d045b397c37 GIT binary patch literal 10380 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