Prosento_RepEx / server /app /api /routes /sessions.py
ChristopherJKoen's picture
Update template sizing/box wrapping fixes
dd94ad9
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",
)