Files
Mileage-Logger/mileage_logger/gui.py

131 lines
4.8 KiB
Python

"""Simple web GUI for the mileage logger.
This module exposes a FastAPI application that wraps the core
functionality of the mileage logger with a minimal HTML front end. It
allows a user to upload a Google Semantic Location History JSON file
and returns an Excel workbook containing their mileage claims. The
application also renders a basic status page showing the detected
itinerary.
Usage
-----
Run the server using uvicorn:
```
uvicorn mileage_logger.gui:app --reload --port 8000
```
Then navigate to ``http://localhost:8000`` in your web browser. Use
the form to upload a JSON export. After processing, the server will
return an Excel file for download.
Limitations
-----------
This GUI is intentionally lightweight and is not designed for
concurrent multi-user access. It does not persist files on disk and
does not perform any authentication or authorisation. For production
use consider extending it with proper user management and storage.
"""
from __future__ import annotations
import json
import os
import tempfile
from io import BytesIO
from typing import Dict, List
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
from .ingest.semantic_reader import load_place_visits
from .logic.detect_itinerary import SiteConfig, detect_itinerary
from .distance.resolve import DistanceResolver
from .export.excel_writer import build_monthly_rows, write_monthly_workbook
# Load configuration once at startup. You can change the path to
# config/sites.yml if you have customised it. The route catalogue is
# loaded on-demand when handling uploads.
DEFAULT_SITE_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "../config/sites.yml")
DEFAULT_ROUTE_CSV_PATH = os.path.join(os.path.dirname(__file__), "../tests/data/routes_golden.csv")
site_config: SiteConfig = SiteConfig.from_yaml(DEFAULT_SITE_CONFIG_PATH)
app = FastAPI(title="Mileage Logger GUI")
@app.get("/", response_class=HTMLResponse)
async def index() -> str:
"""Render a simple upload form."""
return """
<html>
<head>
<title>Mileage Logger</title>
</head>
<body>
<h1>Mileage Logger</h1>
<p>Select a Google Takeout JSON file to process. The file
should contain the "timelineObjects" array from your Semantic
Location History export.</p>
<form action="/process" method="post" enctype="multipart/form-data">
<input type="file" name="file" accept="application/json" required />
<br/><br/>
<label for="vehicle">Vehicle description:</label>
<input type="text" id="vehicle" name="vehicle" value="SH11 DRV (Own 1.6CC Diesel Car/Van)" />
<br/><br/>
<label for="job_role">Job role:</label>
<input type="text" id="job_role" name="job_role" value="ICT Technician" />
<br/><br/>
<input type="submit" value="Process" />
</form>
</body>
</html>
"""
@app.post("/process")
async def process_file(
file: UploadFile = File(...),
vehicle: str = Form("SH11 DRV (Own 1.6CC Diesel Car/Van)"),
job_role: str = Form("ICT Technician"),
) -> StreamingResponse:
"""Handle upload and return an Excel workbook.
The uploaded file is saved to a temporary file on disk and then
passed through the existing CLI pipeline. The resulting workbook
contains one sheet per month and is returned as a streaming
response.
"""
# Persist upload to a temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tmp_in:
contents = await file.read()
tmp_in.write(contents)
tmp_in.flush()
input_path = tmp_in.name
# Parse visits and detect itinerary
visits = load_place_visits(input_path)
hops = detect_itinerary(visits, site_config)
resolver = DistanceResolver(route_csv_path=DEFAULT_ROUTE_CSV_PATH, vehicle_label=vehicle, job_role=job_role)
rows_by_month = build_monthly_rows(hops, site_config, resolver)
# Write workbook to in-memory buffer
output_stream = BytesIO()
# Use openpyxl to write into BytesIO via our helper
# Since write_monthly_workbook writes to a file, create another temp file
with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp_out:
write_monthly_workbook(rows_by_month, tmp_out.name)
tmp_out.flush()
# Read the file back into memory
tmp_out.seek(0)
data = tmp_out.read()
output_stream.write(data)
# Cleanup temporary files
try:
os.remove(input_path)
except Exception:
pass
# Prepare response
output_stream.seek(0)
filename = "mileage.xlsx"
headers = {"Content-Disposition": f"attachment; filename={filename}"}
return StreamingResponse(output_stream, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers=headers)