from fastapi import FastAPI, HTTPException, Depends from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional import pandas as pd from io import BytesIO from fastapi.responses import StreamingResponse import logging from datetime import datetime from dateutil.relativedelta import relativedelta from scrapers import AttendanceScraper, JourneybookScraper from calculators.kilometer_calculator import KilometerCalculator from fillers import JourneybookFiller logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = FastAPI(title="Kniha Jízd API", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) class ScrapeRequest(BaseModel): username: str password: str month: str vehicle_registration: str = "4SH1148" class CalculateRequest(BaseModel): username: str password: str start_date: str # Format: YYYY-MM-DD end_date: str # Format: YYYY-MM-DD start_km: int end_km: int vehicle_registration: str = "4SH1148" variance: float = 0.1 class FillRequest(CalculateRequest): dry_run: bool = True @app.get("/health") async def health_check(): return {"status": "healthy"} @app.post("/api/scrape/attendance") async def scrape_attendance(request: ScrapeRequest): """Scrape attendance data for a month""" try: scraper = AttendanceScraper(request.username, request.password) attendance_dates = scraper.scrape_month(request.month) return { "month": request.month, "sick_vacation_days": attendance_dates, "count": len(attendance_dates) } except Exception as e: logger.error(f"Attendance scraping failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/scrape/journeybook") async def scrape_journeybook(request: ScrapeRequest): """Scrape journeybook data for a month""" try: scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration) df = scraper.scrape_month(request.month) return { "month": request.month, "entries": df.to_dict(orient='records'), "count": len(df) } except Exception as e: logger.error(f"Journeybook scraping failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/calculate") async def calculate_kilometers(request: CalculateRequest): """Scrape data, filter sick days, and recalculate kilometers""" try: logger.info(f"Calculate request: start_date={request.start_date}, end_date={request.end_date}") attendance_scraper = AttendanceScraper(request.username, request.password) journeybook_scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration) # Get all months in the date range start = datetime.strptime(request.start_date, "%Y-%m-%d") end = datetime.strptime(request.end_date, "%Y-%m-%d") logger.info(f"Parsed dates: start={start}, end={end}") # Collect data from all months all_attendance_dates = [] all_dfs = [] current = start while current <= end: month_str = current.strftime("%Y-%m") logger.info(f"Scraping attendance for {month_str}") attendance_dates = attendance_scraper.scrape_month(month_str) all_attendance_dates.extend(attendance_dates) logger.info(f"Scraping journeybook for {month_str}") df_month = journeybook_scraper.scrape_month(month_str) all_dfs.append(df_month) current = current + relativedelta(months=1) # Combine all months df = pd.concat(all_dfs, ignore_index=True) if all_dfs else pd.DataFrame() # Filter by actual date range, but preserve refueling rows (empty Datum) if not df.empty: date_parsed = pd.to_datetime(df["Datum"], format="%d.%m.%Y", errors='coerce') is_refuel = df["Datum"].isna() | (df["Datum"] == "") is_in_range = (date_parsed >= start) & (date_parsed <= end) df = df[is_in_range | is_refuel] logger.info(f"Filtering out {len(all_attendance_dates)} sick/vacation days") # Only filter journey rows, not refueling rows is_refuel = df["Datum"].isna() | (df["Datum"] == "") df = df[~df["Datum"].isin(all_attendance_dates) | is_refuel] logger.info("Recalculating kilometers") df = KilometerCalculator.recalculate(df, request.start_km, request.end_km, request.variance) return { "month": f"{request.start_date} - {request.end_date}", "start_km": request.start_km, "end_km": request.end_km, "filtered_days": len(all_attendance_dates), "entries": df.to_dict(orient='records'), "total_entries": len(df) } except Exception as e: import traceback logger.error(f"Calculate failed: {e}") logger.error(traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/export/excel") async def export_to_excel(request: CalculateRequest): """Generate and download Excel file""" try: attendance_scraper = AttendanceScraper(request.username, request.password) journeybook_scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration) # Get all months in the date range start = datetime.strptime(request.start_date, "%Y-%m-%d") end = datetime.strptime(request.end_date, "%Y-%m-%d") # Collect data from all months all_attendance_dates = [] all_dfs = [] current = start while current <= end: month_str = current.strftime("%Y-%m") attendance_dates = attendance_scraper.scrape_month(month_str) all_attendance_dates.extend(attendance_dates) df_month = journeybook_scraper.scrape_month(month_str) all_dfs.append(df_month) current = current + relativedelta(months=1) # Combine all months df = pd.concat(all_dfs, ignore_index=True) if all_dfs else pd.DataFrame() # Filter by actual date range, but preserve refueling rows (empty Datum) if not df.empty: date_parsed = pd.to_datetime(df["Datum"], format="%d.%m.%Y", errors='coerce') is_refuel = df["Datum"].isna() | (df["Datum"] == "") is_in_range = (date_parsed >= start) & (date_parsed <= end) df = df[is_in_range | is_refuel] # Only filter journey rows, not refueling rows is_refuel = df["Datum"].isna() | (df["Datum"] == "") df = df[~df["Datum"].isin(all_attendance_dates) | is_refuel] df = KilometerCalculator.recalculate(df, request.start_km, request.end_km, request.variance) output = BytesIO() df.to_excel(output, index=False, engine='openpyxl') output.seek(0) filename = f"journeybook_{request.start_date}_{request.end_date}.xlsx" return StreamingResponse( output, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f"attachment; filename={filename}"} ) except Exception as e: logger.error(f"Export failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/fill/journeybook") async def fill_journeybook(request: FillRequest): """Fill calculated data back to kj.colsys.cz (restricted to January 2025)""" try: logger.info(f"Fill request received with dry_run={request.dry_run}") # Only allow January 2025 for testing if request.start_date != "2025-01-01" or request.end_date < "2025-01-31": raise HTTPException(status_code=400, detail="Only January 2025 is allowed for testing") attendance_scraper = AttendanceScraper(request.username, request.password) journeybook_scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration) # Get January data month_str = "2025-01" attendance_dates = attendance_scraper.scrape_month(month_str) df = journeybook_scraper.scrape_month(month_str) # Filter sick/vacation days df = df[~df["Datum"].isin(attendance_dates)] # Recalculate kilometers df = KilometerCalculator.recalculate(df, request.start_km, request.end_km, request.variance) # Fill data back (supports dry_run mode) filler = JourneybookFiller(request.username, request.password, request.vehicle_registration) result = filler.fill_month(df, month_str, dry_run=request.dry_run) return result except Exception as e: import traceback logger.error(f"Fill failed: {e}") logger.error(traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e)) if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)