Initial commit - Journey book (kniha jízd) automation system
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>
This commit is contained in:
243
backend/api/main.py
Normal file
243
backend/api/main.py
Normal file
@@ -0,0 +1,243 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user