from __future__ import annotations import json from pathlib import Path from typing import List from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from fastapi.responses import FileResponse from openpyxl import Workbook from ..deps import get_session_store from ..schemas import ( HeadingsRequest, HeadingsResponse, PageTemplatesRequest, PageTemplatesResponse, PagesRequest, PagesResponse, SectionsRequest, SectionsResponse, SelectionRequest, SessionResponse, SessionStatusResponse, ) from ...services import SessionStore from ...services.session_store import DATA_EXTS, IMAGE_EXTS from ...services.data_import import ( populate_session_from_data_files, reconcile_session_image_links, ) from ...services.pdf_reportlab import render_report_pdf router = APIRouter() def _normalize_session_id(session_id: str, store: SessionStore) -> str: try: return store.validate_session_id(session_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc def _attach_urls(session: dict) -> dict: session = dict(session) uploads = {} for category, items in (session.get("uploads") or {}).items(): enriched = [] for item in items: enriched.append( { **item, "url": f"/api/sessions/{session['id']}/uploads/{item['id']}", } ) uploads[category] = enriched session["uploads"] = uploads return session def _merge_text(primary: str, secondary: str) -> str: primary = (primary or "").strip() secondary = (secondary or "").strip() if not secondary: return primary if not primary: return secondary if secondary in primary: return primary return f"{primary} - {secondary}" @router.get("", response_model=List[SessionResponse]) def list_sessions(store: SessionStore = Depends(get_session_store)) -> List[SessionResponse]: sessions = store.list_sessions() sessions.sort(key=lambda item: item.get("created_at", ""), reverse=True) return [_attach_urls(session) for session in sessions] @router.delete("/{session_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_session( session_id: str, store: SessionStore = Depends(get_session_store) ) -> None: session_id = _normalize_session_id(session_id, store) deleted = store.delete_session(session_id) if not deleted: raise HTTPException(status_code=404, detail="Session not found.") @router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED) def create_session( document_no: str = Form(""), inspection_date: str = Form(""), files: List[UploadFile] = File(...), store: SessionStore = Depends(get_session_store), ) -> SessionResponse: if not files: raise HTTPException(status_code=400, detail="At least one file is required.") session = store.create_session(document_no, inspection_date) saved_files = [] for upload in files: try: saved_files.append(store.save_upload(session["id"], upload)) except ValueError as exc: raise HTTPException(status_code=413, detail=str(exc)) from exc finally: try: upload.file.close() except Exception: pass session = store.add_uploads(session, saved_files) try: session = populate_session_from_data_files(store, session) session = reconcile_session_image_links(store, session) except Exception: # Do not block upload if data parsing fails. pass return _attach_urls(session) @router.get("/{session_id}", response_model=SessionResponse) def get_session(session_id: str, store: SessionStore = Depends(get_session_store)) -> SessionResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") store.ensure_sections(session) try: session = reconcile_session_image_links(store, session) except Exception: pass return _attach_urls(session) @router.get("/{session_id}/status", response_model=SessionStatusResponse) def get_session_status( session_id: str, store: SessionStore = Depends(get_session_store) ) -> SessionStatusResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") return SessionStatusResponse( id=session["id"], status=session["status"], updated_at=session["updated_at"] ) @router.put("/{session_id}/selection", response_model=SessionResponse) def update_selection( session_id: str, payload: SelectionRequest, store: SessionStore = Depends(get_session_store), ) -> SessionResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") session = store.set_selected_photos(session, payload.selected_photo_ids) return _attach_urls(session) @router.get("/{session_id}/pages", response_model=PagesResponse) def get_pages(session_id: str, store: SessionStore = Depends(get_session_store)) -> PagesResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") try: session = reconcile_session_image_links(store, session) except Exception: pass pages = store.ensure_pages(session) return PagesResponse(pages=pages) @router.put("/{session_id}/pages", response_model=PagesResponse) def save_pages( session_id: str, payload: PagesRequest, store: SessionStore = Depends(get_session_store), ) -> PagesResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") session = store.set_pages(session, payload.pages) return PagesResponse(pages=session.get("pages") or []) @router.get("/{session_id}/sections", response_model=SectionsResponse) def get_sections( session_id: str, store: SessionStore = Depends(get_session_store) ) -> SectionsResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") try: session = reconcile_session_image_links(store, session) except Exception: pass sections = store.ensure_sections(session) return SectionsResponse(sections=sections) @router.put("/{session_id}/sections", response_model=SectionsResponse) def save_sections( session_id: str, payload: SectionsRequest, store: SessionStore = Depends(get_session_store), ) -> SectionsResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") session = store.set_sections(session, payload.sections) return SectionsResponse(sections=session.get("jobsheet_sections") or []) @router.put("/{session_id}/headings", response_model=HeadingsResponse) def save_headings( session_id: str, payload: HeadingsRequest, store: SessionStore = Depends(get_session_store), ) -> HeadingsResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") session = store.set_headings(session, payload.headings) return HeadingsResponse(headings=session.get("headings") or []) @router.put("/{session_id}/page-templates", response_model=PageTemplatesResponse) def save_page_templates( session_id: str, payload: PageTemplatesRequest, store: SessionStore = Depends(get_session_store), ) -> PageTemplatesResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") session = store.set_page_templates(session, payload.page_templates) return PageTemplatesResponse(page_templates=session.get("page_templates") or []) @router.get("/{session_id}/uploads/{file_id}") def get_upload( session_id: str, file_id: str, store: SessionStore = Depends(get_session_store), ) -> FileResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") path = store.resolve_upload_path(session, file_id) if not path or not path.exists(): raise HTTPException(status_code=404, detail="File not found.") return FileResponse(path) @router.post("/{session_id}/data-files", response_model=SessionResponse) def upload_data_file( session_id: str, file: UploadFile = File(...), store: SessionStore = Depends(get_session_store), ) -> SessionResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") filename = (file.filename or "").lower() if filename and Path(filename).suffix.lower() not in DATA_EXTS: raise HTTPException(status_code=400, detail="Unsupported data file type.") try: saved_file = store.save_upload(session_id, file) except ValueError as exc: raise HTTPException(status_code=413, detail=str(exc)) from exc finally: try: file.file.close() except Exception: pass session = store.add_uploads(session, [saved_file]) try: session = populate_session_from_data_files(store, session) session = reconcile_session_image_links(store, session) except Exception: pass return _attach_urls(session) @router.post("/{session_id}/uploads", response_model=SessionResponse) def upload_photo( session_id: str, file: UploadFile = File(...), store: SessionStore = Depends(get_session_store), ) -> SessionResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") filename = file.filename or "" if Path(filename).suffix.lower() not in IMAGE_EXTS: raise HTTPException(status_code=400, detail="Unsupported image file type.") try: saved_file = store.save_upload(session_id, file) except ValueError as exc: raise HTTPException(status_code=413, detail=str(exc)) from exc finally: try: file.file.close() except Exception: pass session = store.add_uploads(session, [saved_file]) try: session = reconcile_session_image_links(store, session) except Exception: pass return _attach_urls(session) @router.post("/{session_id}/import-json", response_model=SessionResponse) def import_json( session_id: str, file: UploadFile = File(...), store: SessionStore = Depends(get_session_store), ) -> SessionResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") try: payload = json.loads(file.file.read()) except Exception as exc: raise HTTPException(status_code=400, detail="Invalid JSON file.") from exc finally: try: file.file.close() except Exception: pass imported_session = payload.get("session") if isinstance(payload, dict) else None pages = payload.get("pages") if isinstance(payload, dict) else None sections = payload.get("sections") if isinstance(payload, dict) else None if not sections and isinstance(imported_session, dict): sections = imported_session.get("jobsheet_sections") if not pages and isinstance(imported_session, dict): pages = imported_session.get("pages") if sections: session = store.set_sections(session, sections) if pages: session = store.set_pages(session, pages) if isinstance(imported_session, dict): document_no = _merge_text( imported_session.get("document_no", ""), imported_session.get("project_name", ""), ) if document_no: session["document_no"] = document_no if imported_session.get("inspection_date") is not None: session["inspection_date"] = imported_session["inspection_date"] if imported_session.get("selected_photo_ids") is not None: session["selected_photo_ids"] = imported_session["selected_photo_ids"] if imported_session.get("page_count") is not None: session["page_count"] = imported_session["page_count"] if imported_session.get("headings") is not None: session["headings"] = imported_session["headings"] if imported_session.get("page_templates") is not None: session["page_templates"] = imported_session["page_templates"] store.update_session(session) return _attach_urls(session) @router.get("/{session_id}/export") def export_package( session_id: str, store: SessionStore = Depends(get_session_store) ) -> FileResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") sections = store.ensure_sections(session) pages = store.ensure_pages(session) export_path = Path(store.session_dir(session_id)) / "export.json" payload = { "session": session, "pages": pages, "sections": sections, "exported_at": session.get("updated_at"), } export_path.write_text( json.dumps(payload, indent=2), encoding="utf-8" ) return FileResponse( export_path, media_type="application/json", filename=f"repex_report_{session_id}.json", ) @router.get("/{session_id}/export.pdf") def export_pdf( session_id: str, store: SessionStore = Depends(get_session_store) ) -> FileResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") try: session = reconcile_session_image_links(store, session) except Exception: pass sections = store.ensure_sections(session) export_path = Path(store.session_dir(session_id)) / "export.pdf" render_report_pdf(store, session, sections, export_path) return FileResponse( export_path, media_type="application/pdf", filename=f"repex_report_{session_id}.pdf", ) @router.get("/{session_id}/export.xlsx") def export_excel( session_id: str, store: SessionStore = Depends(get_session_store) ) -> FileResponse: session_id = _normalize_session_id(session_id, store) session = store.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found.") pages = store.ensure_pages(session) first_template = (pages[0].get("template") or {}) if pages else {} wb = Workbook() ws_general = wb.active ws_general.title = "General Information" ws_general.append(["Document No", session.get("document_no", "")]) ws_general.append(["Inspection Date", session.get("inspection_date", "")]) ws_general.append(["Inspector", first_template.get("inspector", "")]) ws_general.append(["Company Logo Image Name", first_template.get("company_logo", "")]) ws_headings = wb.create_sheet("Headings") ws_headings.append(["Heading Number", "Heading Name"]) headings = session.get("headings") or [] if isinstance(headings, dict): headings = [{"number": key, "name": value} for key, value in headings.items()] for heading in headings: if not isinstance(heading, dict): continue ws_headings.append( [heading.get("number", ""), heading.get("name", "")] ) ws_items = wb.create_sheet("Item Spesific") ws_items.append( [ "REF", "Area", "Location", "Item Description", "Category", "Priority", "Required Action", "Figure Caption", "Image Name 1", "Image Name 2", "Image Name 3", "Image Name 4", "Image Name 5", "Image Name 6", ] ) upload_lookup = { item.get("id"): item.get("name") for item in (session.get("uploads") or {}).get("photos", []) } for page in pages: template = page.get("template") or {} photo_names = [ upload_lookup.get(photo_id, "") for photo_id in (page.get("photo_ids") or []) ] while len(photo_names) < 6: photo_names.append("") ws_items.append( [ template.get("reference", ""), template.get("area", ""), template.get("functional_location", ""), template.get("item_description", ""), template.get("category", ""), template.get("priority", ""), template.get("required_action", ""), template.get("figure_caption", ""), *photo_names[:6], ] ) export_path = Path(store.session_dir(session_id)) / "export.xlsx" wb.save(export_path) return FileResponse( export_path, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename=f"repex_report_{session_id}.xlsx", )