Files
zynktime/main.py
T

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()