Initial commit (clean, ignores in place)
This commit is contained in:
131
mileage_logger/gui.py
Normal file
131
mileage_logger/gui.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""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)
|
Reference in New Issue
Block a user