first commit

This commit is contained in:
2025-12-02 21:26:08 +01:00
commit 790f4a9f1d
23 changed files with 4085 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
import csv
import io
import logging
from datetime import date, datetime
from typing import Dict, List
from openpyxl import load_workbook
from config import Config
def validate_date(date_text: str) -> bool:
"""
Validate if date string is in ISO format (YYYY-MM-DD).
Args:
date_text: Date string to validate
Returns:
True if valid, False otherwise
"""
try:
datetime.fromisoformat(date_text)
return True
except ValueError:
return False
def parse_csv(file_content: str) -> List[List[str]]:
"""
Parse CSV content into rows with automatic delimiter detection.
Args:
file_content: CSV file content as string
Returns:
List of rows, where each row is a list of values
"""
# Use csv.Sniffer to auto-detect delimiter
try:
sample = file_content[:1024] # Use first 1KB for detection
sniffer = csv.Sniffer()
delimiter = sniffer.sniff(sample).delimiter
logging.info(f"Detected CSV delimiter: '{delimiter}'")
except Exception as e:
# Fallback to comma if detection fails
logging.warning(f"Could not detect delimiter, using comma: {e}")
delimiter = ','
reader = csv.reader(io.StringIO(file_content), delimiter=delimiter)
return list(reader)
def parse_xlsx(file_bytes: bytes) -> List[List[str]]:
"""
Parse XLSX content into rows from the configured worksheet.
Args:
file_bytes: XLSX file content as bytes
Returns:
List of rows, where each row is a list of values
Raises:
ValueError: If the worksheet is missing or the file cannot be read
"""
try:
workbook = load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True)
except Exception as exc: # pragma: no cover - relies on openpyxl internals
raise ValueError(f"Failed to read XLSX file: {exc}") from exc
if Config.XLSX_SHEET_NAME not in workbook.sheetnames:
raise ValueError(f"Worksheet '{Config.XLSX_SHEET_NAME}' not found in XLSX file")
sheet = workbook[Config.XLSX_SHEET_NAME]
rows: List[List[str]] = []
for row in sheet.iter_rows(values_only=True):
cleaned_row = []
for cell in row:
if cell is None:
cleaned_row.append("")
elif isinstance(cell, datetime):
cleaned_row.append(cell.date().isoformat())
elif isinstance(cell, date):
cleaned_row.append(cell.isoformat())
else:
cleaned_row.append(str(cell).strip())
if any(value for value in cleaned_row):
rows.append(cleaned_row)
return rows
def extract_work_time(rows: List[List[str]]) -> Dict[str, float]:
"""
Extract and aggregate work time from CSV rows.
Args:
rows: A list of lists representing the rows of a CSV file
Returns:
A dictionary mapping dates (in 'YYYY-MM-DD' format) to total hours worked
"""
work_time: Dict[str, float] = {}
# Try to detect column indices from header
date_col_idx = Config.DATE_COL_INDEX
hours_col_idx = Config.HOURS_COL_INDEX
if rows and len(rows) > 0:
header = [str(col).strip().lower() for col in rows[0]]
# Look for date-related columns
for idx, col in enumerate(header):
if 'date' in col or 'vouch' in col:
date_col_idx = idx
logging.info(f"Detected date column at index {idx}: '{rows[0][idx]}'")
break
# Look for hours-related columns
for idx, col in enumerate(header):
if 'hour' in col or 'time' in col and 'code' not in col:
hours_col_idx = idx
logging.info(f"Detected hours column at index {idx}: '{rows[0][idx]}'")
break
for i, row in enumerate(rows):
# Skip header row
if i == 0:
continue
# Skip short rows
if len(row) <= max(date_col_idx, hours_col_idx):
logging.debug(
f"Skipping row {i+1} as it has fewer than "
f"{max(date_col_idx, hours_col_idx)+1} columns."
)
continue
date_str = row[date_col_idx].strip() if date_col_idx < len(row) else ""
hours_str = row[hours_col_idx].strip() if hours_col_idx < len(row) else ""
if not date_str or not hours_str:
logging.debug(f"Skipping row {i+1} due to missing date or hours.")
continue
try:
# Parse date, trying API format first, then CSV format
try:
parsed_date = datetime.strptime(date_str, Config.API_DATE_FORMAT)
except ValueError:
parsed_date = datetime.strptime(date_str, Config.CSV_DATE_FORMAT)
api_formatted_date = parsed_date.strftime(Config.API_DATE_FORMAT)
# Parse hours, allowing for comma as decimal separator and whitespace
hours = float(hours_str.replace(',', '.').strip())
if hours > Config.MIN_HOURS_THRESHOLD:
work_time[api_formatted_date] = work_time.get(api_formatted_date, 0.0) + hours
else:
logging.debug(
f"Skipping row {i+1} with hours below threshold. Hours: {hours}"
)
except (ValueError, IndexError) as e:
logging.debug(f"Skipping row {i+1} due to parsing error: {e}")
logging.info(f"Extracted work time for {len(work_time)} unique dates.")
return work_time
def create_time_event(
user_id: int,
project_id: int,
activity: Dict,
date: str,
hours: float
) -> Dict:
"""
Create a time event dictionary for the Kleer API.
Args:
user_id: User ID
project_id: Project ID
activity: Activity dictionary
date: Date in YYYY-MM-DD format
hours: Number of hours
Returns:
Time event dictionary
"""
return {
"foreign-id": "",
"user": {"id": user_id},
"client-project": project_id,
"activity": activity,
"date": date,
"hours": hours,
"comment": "",
"internal-comment": ""
}
def get_user_by_name(users_data: Dict, username: str) -> int:
"""
Get user ID by username from users data.
Args:
users_data: User data from API
username: Username to search for
Returns:
User ID
Raises:
ValueError: If user not found
"""
if not users_data or "users" not in users_data:
raise ValueError("Invalid user data received from API")
users = {
user.get('name'): user.get('id')
for user in users_data.get("users", [])
if isinstance(user, dict)
}
user_id = users.get(username)
if not user_id:
raise ValueError(f"User '{username}' not found")
return user_id