Features: - FastAPI backend for scraping attendance and journey book data - Deterministic kilometer distribution with random variance - Refueling form filling with km values - Next.js frontend with date range selector - Docker deployment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
244 lines
9.1 KiB
Python
244 lines
9.1 KiB
Python
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:
|
|
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")
|
|
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)
|