370 lines
10 KiB
Python
370 lines
10 KiB
Python
"""
|
|
ZynkTime - Time reporting application for Kleer API.
|
|
|
|
This module provides FastAPI endpoints for time reporting and a CLI interface.
|
|
"""
|
|
import logging
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
import fastapi
|
|
import uvicorn
|
|
from fastapi import File, HTTPException, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import HTMLResponse, FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from api_client import KleerAPI
|
|
from cli import run_cli
|
|
from config import Config
|
|
from models import TimeReportResponse
|
|
from services import (
|
|
create_time_event,
|
|
extract_work_time,
|
|
get_user_by_name,
|
|
parse_csv,
|
|
parse_xlsx,
|
|
validate_date,
|
|
)
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
|
|
|
# Initialize FastAPI app
|
|
app = fastapi.FastAPI(
|
|
title="ZynkTime API",
|
|
description="Time reporting API for Kleer",
|
|
version="1.0.0"
|
|
)
|
|
|
|
# Add CORS middleware
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Mount static files
|
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def root():
|
|
"""Serve the web UI."""
|
|
return FileResponse("static/index.html")
|
|
|
|
|
|
@app.get("/api")
|
|
def api_info():
|
|
"""API information endpoint."""
|
|
return {
|
|
"name": "ZynkTime API",
|
|
"version": "1.0.0",
|
|
"endpoints": {
|
|
"projects": "/projects",
|
|
"report_time": "/report-time"
|
|
}
|
|
}
|
|
|
|
|
|
@app.get("/health")
|
|
def health_check():
|
|
"""Health check endpoint."""
|
|
try:
|
|
Config.validate_config()
|
|
return {"status": "healthy", "api_key_configured": True}
|
|
except ValueError:
|
|
return {"status": "unhealthy", "api_key_configured": False}
|
|
|
|
|
|
@app.get("/projects")
|
|
def list_projects():
|
|
"""
|
|
List all active projects for the configured user.
|
|
|
|
Returns:
|
|
Dictionary of project names to project information
|
|
|
|
Raises:
|
|
HTTPException: If user not found or API request fails
|
|
"""
|
|
try:
|
|
kleer_api = KleerAPI()
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
users_data = kleer_api.get_user_info()
|
|
username = Config.get_username()
|
|
|
|
try:
|
|
user_id = get_user_by_name(users_data, username)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
projects = kleer_api.get_projects(user_id=user_id)
|
|
return projects
|
|
|
|
|
|
@app.post("/preview-time")
|
|
async def preview_time_from_csv(file: UploadFile = File(...)):
|
|
"""
|
|
Parse a file and return the extracted work time without submitting.
|
|
"""
|
|
content = await file.read()
|
|
filename = (file.filename or "").lower()
|
|
content_type = (file.content_type or "").lower()
|
|
|
|
try:
|
|
if filename.endswith(".xlsx") or "spreadsheetml" in content_type:
|
|
rows = parse_xlsx(content)
|
|
else:
|
|
rows = parse_csv(content.decode('utf-8-sig'))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Failed to parse file: {str(e)}")
|
|
|
|
work_time = extract_work_time(rows)
|
|
|
|
if not work_time:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="No valid work time found in the uploaded file."
|
|
)
|
|
|
|
return [{"date": k, "hours": v} for k, v in sorted(work_time.items())]
|
|
|
|
|
|
@app.post("/report-time", response_model=TimeReportResponse)
|
|
async def report_time_from_csv(
|
|
project_name: str,
|
|
excluded_dates: str = "",
|
|
file: UploadFile = File(...)
|
|
):
|
|
"""
|
|
Upload a CSV file and report time to a specified Kleer project.
|
|
|
|
Args:
|
|
project_name: Name of the project to report time to
|
|
file: CSV file containing time entries
|
|
|
|
Returns:
|
|
TimeReportResponse with summary of reported time
|
|
|
|
Raises:
|
|
HTTPException: If validation fails or API request fails
|
|
"""
|
|
# Initialize API client
|
|
try:
|
|
kleer_api = KleerAPI()
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# Get user ID
|
|
users_data = kleer_api.get_user_info()
|
|
username = Config.get_username()
|
|
|
|
try:
|
|
user_id = get_user_by_name(users_data, username)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
# Get project info
|
|
projects = kleer_api.get_projects(user_id=user_id)
|
|
project_info = projects.get(project_name)
|
|
|
|
if not project_info:
|
|
available_projects = ", ".join(projects.keys())
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Project '{project_name}' not found. Available projects: {available_projects}"
|
|
)
|
|
|
|
# Process file content
|
|
content = await file.read()
|
|
filename = (file.filename or "").lower()
|
|
content_type = (file.content_type or "").lower()
|
|
|
|
try:
|
|
if filename.endswith(".xlsx") or "spreadsheetml" in content_type:
|
|
rows = parse_xlsx(content)
|
|
else:
|
|
rows = parse_csv(content.decode('utf-8-sig'))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Failed to parse file: {str(e)}")
|
|
|
|
work_time = extract_work_time(rows)
|
|
|
|
if excluded_dates:
|
|
excluded_list = [d.strip() for d in excluded_dates.split(",")]
|
|
work_time = {k: v for k, v in work_time.items() if k not in excluded_list}
|
|
|
|
if not work_time:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="No valid work time found in the uploaded CSV after filtering."
|
|
)
|
|
|
|
# Submit events
|
|
total_work_time = 0.0
|
|
failed_dates = []
|
|
|
|
for date, total_time in work_time.items():
|
|
try:
|
|
event = create_time_event(
|
|
user_id=user_id,
|
|
project_id=project_info["id"],
|
|
activity=project_info["activity"],
|
|
date=date,
|
|
hours=total_time
|
|
)
|
|
kleer_api.put_event(event)
|
|
total_work_time += total_time
|
|
except Exception as e:
|
|
logging.error(f"Failed to report time for {date}: {e}")
|
|
failed_dates.append(date)
|
|
|
|
# Check if any submissions failed
|
|
if failed_dates:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to report time for dates: {', '.join(failed_dates)}"
|
|
)
|
|
|
|
return TimeReportResponse(
|
|
message="Time reported successfully.",
|
|
reported_days=len(work_time),
|
|
total_hours=total_work_time,
|
|
project_name=project_name,
|
|
user_name=username
|
|
)
|
|
|
|
@app.get("/salary")
|
|
def get_salary_info():
|
|
"""
|
|
Placeholder endpoint for salary information.
|
|
|
|
Returns:
|
|
Static message indicating feature is under development.
|
|
"""
|
|
# Initialize API client
|
|
try:
|
|
kleer_api = KleerAPI()
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# Get user ID
|
|
users_data = kleer_api.get_user_info()
|
|
username = Config.get_username()
|
|
|
|
try:
|
|
user_id = get_user_by_name(users_data, username)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
|
|
return {"message": "Salary information feature is under development."}
|
|
|
|
|
|
def main():
|
|
"""Main entry point for the application."""
|
|
if len(sys.argv) > 1 and sys.argv[1] == 'cli':
|
|
# Run CLI mode
|
|
run_cli()
|
|
else:
|
|
# Run API server
|
|
print("Starting ZynkTime FastAPI server...")
|
|
print("To run CLI mode, use: python main.py cli")
|
|
print(f"Server will be available at http://{Config.SERVER_HOST}:{Config.SERVER_PORT}")
|
|
print("API documentation available at /docs")
|
|
|
|
uvicorn.run(
|
|
app,
|
|
host=Config.SERVER_HOST,
|
|
port=Config.SERVER_PORT
|
|
)
|
|
|
|
|
|
def _get_user_id(kleer_api: KleerAPI) -> int:
|
|
"""Resolve configured username to user ID using API data."""
|
|
users_data = kleer_api.get_user_info()
|
|
username = Config.get_username()
|
|
|
|
try:
|
|
return get_user_by_name(users_data, username)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
def _validate_date_range(start_date: str, end_date: str) -> None:
|
|
"""Validate date strings and ordering."""
|
|
if not (validate_date(start_date) and validate_date(end_date)):
|
|
raise HTTPException(status_code=400, detail="Dates must be in YYYY-MM-DD format")
|
|
|
|
if datetime.fromisoformat(start_date) > datetime.fromisoformat(end_date):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Start date must be on or before end date"
|
|
)
|
|
|
|
|
|
@app.get("/events")
|
|
def list_events(start_date: str, end_date: str):
|
|
"""
|
|
List events for the configured user between two dates (inclusive).
|
|
"""
|
|
_validate_date_range(start_date, end_date)
|
|
|
|
try:
|
|
kleer_api = KleerAPI()
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
user_id = _get_user_id(kleer_api)
|
|
|
|
events = kleer_api.get_events(user_id=user_id, start_date=start_date, end_date=end_date)
|
|
return {
|
|
"user_id": user_id,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"events": events.get("event-readables", events)
|
|
}
|
|
|
|
|
|
@app.post("/approve-events")
|
|
def approve_events(start_date: str, end_date: str):
|
|
"""
|
|
Approve events for the configured user in a date range.
|
|
"""
|
|
_validate_date_range(start_date, end_date)
|
|
|
|
try:
|
|
kleer_api = KleerAPI()
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
user_id = _get_user_id(kleer_api)
|
|
|
|
approval_response = kleer_api.approve_events(
|
|
user_id=user_id,
|
|
start_date=start_date,
|
|
end_date=end_date
|
|
)
|
|
|
|
return {
|
|
"message": "Events approved",
|
|
"user_id": user_id,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"approval": approval_response
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|