import csv import json import re from pathlib import Path from fastapi import APIRouter, File, HTTPException, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel from tools.doc_generator.logic.document_types import get_document_type, list_document_types from tools.doc_generator.logic.renderer import generate_docx from tools.doc_generator.logic.excel_mapper import export_profile_excel, load_excel_maps, load_excel_map, resolve_excel_template router = APIRouter() PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent EXPORTS_DIR = PROJECT_ROOT / "exports" INPUTS_DIR = PROJECT_ROOT / "inputs" UPLOADS_DIR = INPUTS_DIR / "uploads" JSON_UPLOADS_DIR = UPLOADS_DIR / "json" DATA_UPLOADS_DIR = UPLOADS_DIR / "data" TEMPLATE_UPLOADS_DIR = UPLOADS_DIR / "templates" class ExportExcelRequest(BaseModel): document_type_id: str data: dict map_id: str = "legacy_datafile" class GenerateDocRequest(BaseModel): document_type_id: str data: dict template_id: str | None = None def safe_filename(filename: str) -> str: filename = Path(filename or "upload").name filename = re.sub(r"[^A-Za-z0-9._ -]+", "", filename) filename = re.sub(r"\s+", "_", filename).strip("._ ") return filename or "upload" def save_upload(upload: UploadFile, directory: Path) -> Path: directory.mkdir(parents=True, exist_ok=True) filename = safe_filename(upload.filename) path = directory / filename counter = 1 while path.exists(): stem = path.stem suffix = path.suffix path = directory / f"{stem}_{counter}{suffix}" counter += 1 with path.open("wb") as f: f.write(upload.file.read()) return path @router.get("/document-types") def document_types(): return {"document_types": list_document_types()} @router.get("/document-types/{document_type_id}") def document_type(document_type_id: str): try: return get_document_type(document_type_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) @router.post("/upload-json") async def upload_json(file: UploadFile = File(...)): if not file.filename.lower().endswith(".json"): raise HTTPException(status_code=400, detail="Upload must be a JSON file.") path = save_upload(file, JSON_UPLOADS_DIR) try: data = json.loads(path.read_text(encoding="utf-8")) except Exception as exc: raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}") return { "ok": True, "filename": path.name, "saved_path": str(path.relative_to(PROJECT_ROOT)), "json": data, } @router.get("/uploaded-json") def uploaded_json_files(): JSON_UPLOADS_DIR.mkdir(parents=True, exist_ok=True) files = [] for path in sorted(JSON_UPLOADS_DIR.glob("*.json")): files.append({ "filename": path.name, "saved_path": str(path.relative_to(PROJECT_ROOT)), "modified": path.stat().st_mtime, }) return {"files": files} @router.get("/uploaded-json/{filename}") def get_uploaded_json(filename: str): file_path = JSON_UPLOADS_DIR / safe_filename(filename) if not file_path.exists(): raise HTTPException(status_code=404, detail="Uploaded JSON not found.") try: data = json.loads(file_path.read_text(encoding="utf-8")) except Exception as exc: raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}") return { "ok": True, "filename": file_path.name, "saved_path": str(file_path.relative_to(PROJECT_ROOT)), "json": data, } @router.delete("/uploaded-json/{filename}") def delete_uploaded_json(filename: str): file_path = JSON_UPLOADS_DIR / safe_filename(filename) if not file_path.exists(): raise HTTPException(status_code=404, detail="Uploaded JSON not found.") file_path.unlink() return { "ok": True, "deleted": file_path.name, } @router.post("/upload-data") async def upload_data(file: UploadFile = File(...)): if not file.filename.lower().endswith(".csv"): raise HTTPException(status_code=400, detail="For now, upload data as CSV.") path = save_upload(file, DATA_UPLOADS_DIR) try: with path.open("r", encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) rows = list(reader) except Exception as exc: raise HTTPException(status_code=400, detail=f"Could not read CSV: {exc}") if not rows: raise HTTPException(status_code=400, detail="CSV has no data rows.") return { "ok": True, "filename": path.name, "saved_path": str(path.relative_to(PROJECT_ROOT)), "data": rows[0], "row_count": len(rows), } @router.post("/upload-template/{document_type_id}") async def upload_template(document_type_id: str, file: UploadFile = File(...)): if not file.filename.lower().endswith(".docx"): raise HTTPException(status_code=400, detail="Upload must be a DOCX template.") # Validate profile exists. try: get_document_type(document_type_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) profile_dir = TEMPLATE_UPLOADS_DIR / safe_filename(document_type_id) path = save_upload(file, profile_dir) return { "ok": True, "filename": path.name, "template_id": f"uploaded:{path.name}", "saved_path": str(path.relative_to(PROJECT_ROOT)), } @router.get("/uploaded-templates/{document_type_id}") def uploaded_templates(document_type_id: str): profile_dir = TEMPLATE_UPLOADS_DIR / safe_filename(document_type_id) profile_dir.mkdir(parents=True, exist_ok=True) templates = [] for path in sorted(profile_dir.glob("*.docx")): templates.append({ "id": f"uploaded:{path.name}", "label": path.name, "filename": path.name, "saved_path": str(path.relative_to(PROJECT_ROOT)), "modified": path.stat().st_mtime, }) return {"templates": templates} @router.delete("/uploaded-template/{document_type_id}/{template_id}") def delete_uploaded_template(document_type_id: str, template_id: str): doc_dir = TEMPLATE_UPLOADS_DIR / safe_filename(document_type_id) file_path = doc_dir / safe_filename(template_id) if not file_path.exists(): raise HTTPException(status_code=404, detail="Template not found.") file_path.unlink() return {"deleted": True, "template_id": file_path.name} @router.get("/excel-maps/{document_type_id}") def get_excel_maps_route(document_type_id: str): try: maps = load_excel_maps(document_type_id) except FileNotFoundError: return {"maps": []} return { "maps": [ { "id": item.get("id"), "label": item.get("label", item.get("id")), "field_count": len(item.get("field_to_cell", {})), "fields": sorted(item.get("field_to_cell", {}).keys()), "template": item.get("template"), "mode": item.get("mode", "map"), } for item in maps ] } @router.post("/export-excel") def export_excel(request: ExportExcelRequest): try: output_path = export_profile_excel( request.document_type_id, request.data, request.map_id, ) return { "filename": output_path.name, "download_url": f"/api/doc-generator/download/{output_path.name}", } except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) @router.post("/generate") def generate_document(request: GenerateDocRequest): try: output_path = generate_docx(request.document_type_id, request.data, request.template_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) return { "ok": True, "filename": output_path.name, "download_url": f"/api/doc-generator/download/{output_path.name}" } @router.get("/excel-template/{template_name}") def download_excel_template(template_name: str): allowed = { "datafile": "template.xlsx", "amortization": "amortization.xlsx", } filename = allowed.get(template_name) if not filename: raise HTTPException(status_code=404, detail="Unknown Excel template.") path = OLD_WORD_DOC_GENERATOR_DIR / filename if not path.exists(): raise HTTPException(status_code=404, detail=f"Excel template not found: {path}") return FileResponse( path, filename=filename, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) @router.get("/excel-template-by-map/{document_type_id}/{map_id}") def download_excel_template_by_map(document_type_id: str, map_id: str): try: excel_map = load_excel_map(document_type_id, map_id) template_path = resolve_excel_template(excel_map.get("template")) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) return FileResponse( path=template_path, filename=template_path.name, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) @router.delete("/uploaded-template/{document_type_id}/{filename}") def delete_uploaded_template(document_type_id: str, filename: str): folder = TEMPLATE_UPLOADS_DIR / safe_filename(document_type_id) path = folder / safe_filename(filename) if not path.exists(): raise HTTPException(status_code=404, detail="Uploaded template not found.") if path.suffix.lower() != ".docx": raise HTTPException(status_code=400, detail="Only DOCX templates can be deleted here.") path.unlink() return {"deleted": True, "filename": path.name} @router.get("/download/{filename}") def download_file(filename: str): safe_name = safe_filename(filename) path = EXPORTS_DIR / safe_name if not path.exists(): raise HTTPException(status_code=404, detail="File not found.") suffix = path.suffix.lower() media_types = { ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".csv": "text/csv", ".json": "application/json", ".pdf": "application/pdf", } return FileResponse( path, filename=path.name, media_type=media_types.get(suffix, "application/octet-stream"), )