383 lines
12 KiB
Python
383 lines
12 KiB
Python
"""
|
|
Generic Excel API Service
|
|
|
|
A FastAPI service for reading and writing Excel files (.xlsx).
|
|
All file paths are relative to /data/ mount.
|
|
|
|
Endpoints:
|
|
- GET /health - Health check
|
|
- GET /excel/sheets - List all sheet names
|
|
- GET /excel/schema - Get column headers
|
|
- GET /excel/read - Read all rows
|
|
- GET /excel/row/{row_num} - Get specific row (1-indexed)
|
|
- GET /excel/find - Find row by column value
|
|
- POST /excel/row - Add new row
|
|
- PUT /excel/row/{row_num} - Update row
|
|
- POST /excel/create - Create new workbook
|
|
"""
|
|
|
|
from fastapi import FastAPI, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from pathlib import Path
|
|
from typing import Optional, List, Any
|
|
from openpyxl import Workbook, load_workbook
|
|
from openpyxl.styles import Font, Alignment
|
|
import os
|
|
|
|
app = FastAPI(
|
|
title="JingTian Tools Service",
|
|
description="Generic Excel API for reading and writing xlsx files",
|
|
version="0.3.0",
|
|
)
|
|
|
|
DATA_ROOT = Path(os.getenv("DATA_ROOT", "/data"))
|
|
|
|
|
|
# Request models
|
|
class AddRowRequest(BaseModel):
|
|
file_path: str
|
|
sheet_name: str
|
|
header_row: int = 1
|
|
row: dict[str, Any]
|
|
|
|
|
|
class UpdateRowRequest(BaseModel):
|
|
file_path: str
|
|
sheet_name: str
|
|
header_row: int = 1
|
|
row: dict[str, Any]
|
|
|
|
|
|
class CreateWorkbookRequest(BaseModel):
|
|
file_path: str
|
|
sheet_name: str
|
|
columns: List[str]
|
|
|
|
|
|
def get_workbook_readonly(file_path: str):
|
|
"""Load workbook from file path relative to DATA_ROOT (read-only)."""
|
|
full_path = DATA_ROOT / file_path
|
|
if not full_path.exists():
|
|
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
|
|
if not full_path.suffix.lower() == ".xlsx":
|
|
raise HTTPException(status_code=400, detail="Only .xlsx files supported")
|
|
try:
|
|
return load_workbook(full_path, read_only=True, data_only=True)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=500, detail=f"Failed to open workbook: {str(e)}"
|
|
)
|
|
|
|
|
|
def get_workbook_writable(file_path: str):
|
|
"""Load workbook from file path relative to DATA_ROOT (writable)."""
|
|
full_path = DATA_ROOT / file_path
|
|
if not full_path.exists():
|
|
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
|
|
if not full_path.suffix.lower() == ".xlsx":
|
|
raise HTTPException(status_code=400, detail="Only .xlsx files supported")
|
|
try:
|
|
return load_workbook(full_path), full_path
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=500, detail=f"Failed to open workbook: {str(e)}"
|
|
)
|
|
|
|
|
|
def get_sheet(wb, sheet_name: str):
|
|
"""Get sheet by name."""
|
|
if sheet_name not in wb.sheetnames:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Sheet '{sheet_name}' not found. Available: {wb.sheetnames}",
|
|
)
|
|
return wb[sheet_name]
|
|
|
|
|
|
def cell_to_str(cell_value) -> str:
|
|
"""Convert cell value to string, empty string for None."""
|
|
if cell_value is None:
|
|
return ""
|
|
return str(cell_value)
|
|
|
|
|
|
def get_headers(ws, header_row: int) -> list[str]:
|
|
"""Get headers from a worksheet."""
|
|
headers = []
|
|
for cell in ws[header_row]:
|
|
headers.append(cell_to_str(cell.value))
|
|
return headers
|
|
|
|
|
|
def get_column_index(headers: list[str], column_name: str) -> int:
|
|
"""Get 1-indexed column index for a header name."""
|
|
try:
|
|
return headers.index(column_name) + 1
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Column '{column_name}' not found. Available: {headers}",
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "healthy", "service": "tools", "version": "0.3.0"}
|
|
|
|
|
|
@app.get("/excel/sheets")
|
|
async def list_sheets(
|
|
file_path: str = Query(..., description="Path to xlsx file relative to /data/"),
|
|
):
|
|
"""List all sheet names in the workbook."""
|
|
wb = get_workbook_readonly(file_path)
|
|
sheets = wb.sheetnames
|
|
wb.close()
|
|
return {"file_path": file_path, "sheets": sheets, "count": len(sheets)}
|
|
|
|
|
|
@app.get("/excel/schema")
|
|
async def get_schema(
|
|
file_path: str = Query(..., description="Path to xlsx file relative to /data/"),
|
|
sheet_name: str = Query(..., description="Sheet name to read"),
|
|
header_row: int = Query(1, description="Row number containing headers (1-indexed)"),
|
|
):
|
|
"""Get column headers from the specified sheet."""
|
|
wb = get_workbook_readonly(file_path)
|
|
ws = get_sheet(wb, sheet_name)
|
|
headers = get_headers(ws, header_row)
|
|
wb.close()
|
|
return {
|
|
"file_path": file_path,
|
|
"sheet_name": sheet_name,
|
|
"header_row": header_row,
|
|
"columns": headers,
|
|
"column_count": len(headers),
|
|
}
|
|
|
|
|
|
@app.get("/excel/read")
|
|
async def read_all(
|
|
file_path: str = Query(..., description="Path to xlsx file relative to /data/"),
|
|
sheet_name: str = Query(..., description="Sheet name to read"),
|
|
header_row: int = Query(1, description="Row number containing headers (1-indexed)"),
|
|
start_row: Optional[int] = Query(
|
|
None,
|
|
description="Start reading from this row (1-indexed, defaults to header_row + 1)",
|
|
),
|
|
limit: Optional[int] = Query(None, description="Max rows to return"),
|
|
):
|
|
"""Read all rows from the specified sheet."""
|
|
wb = get_workbook_readonly(file_path)
|
|
ws = get_sheet(wb, sheet_name)
|
|
headers = get_headers(ws, header_row)
|
|
data_start = start_row if start_row else header_row + 1
|
|
|
|
rows = []
|
|
row_count = 0
|
|
for row_num, row in enumerate(ws.iter_rows(min_row=data_start), start=data_start):
|
|
if limit and row_count >= limit:
|
|
break
|
|
|
|
values = [cell_to_str(cell.value) for cell in row]
|
|
if all(v == "" for v in values):
|
|
continue
|
|
|
|
row_data = {"_row_num": row_num}
|
|
for i, header in enumerate(headers):
|
|
if i < len(values):
|
|
key = header if header else f"_col_{i + 1}"
|
|
row_data[key] = values[i]
|
|
|
|
rows.append(row_data)
|
|
row_count += 1
|
|
|
|
wb.close()
|
|
return {
|
|
"file_path": file_path,
|
|
"sheet_name": sheet_name,
|
|
"columns": headers,
|
|
"rows": rows,
|
|
"row_count": len(rows),
|
|
}
|
|
|
|
|
|
@app.get("/excel/row/{row_num}")
|
|
async def get_row(
|
|
row_num: int,
|
|
file_path: str = Query(..., description="Path to xlsx file relative to /data/"),
|
|
sheet_name: str = Query(..., description="Sheet name to read"),
|
|
header_row: int = Query(1, description="Row number containing headers (1-indexed)"),
|
|
):
|
|
"""Get a specific row by row number (1-indexed)."""
|
|
if row_num < 1:
|
|
raise HTTPException(status_code=400, detail="Row number must be >= 1")
|
|
|
|
wb = get_workbook_readonly(file_path)
|
|
ws = get_sheet(wb, sheet_name)
|
|
headers = get_headers(ws, header_row)
|
|
|
|
try:
|
|
row = ws[row_num]
|
|
except Exception:
|
|
wb.close()
|
|
raise HTTPException(status_code=404, detail=f"Row {row_num} not found")
|
|
|
|
values = [cell_to_str(cell.value) for cell in row]
|
|
row_data = {"_row_num": row_num}
|
|
for i, header in enumerate(headers):
|
|
if i < len(values):
|
|
key = header if header else f"_col_{i + 1}"
|
|
row_data[key] = values[i]
|
|
|
|
wb.close()
|
|
return {"file_path": file_path, "sheet_name": sheet_name, "row": row_data}
|
|
|
|
|
|
@app.get("/excel/find")
|
|
async def find_row(
|
|
file_path: str = Query(..., description="Path to xlsx file relative to /data/"),
|
|
sheet_name: str = Query(..., description="Sheet name to search"),
|
|
header_row: int = Query(1, description="Row number containing headers (1-indexed)"),
|
|
column: str = Query(..., description="Column name to search"),
|
|
value: str = Query(..., description="Value to find"),
|
|
):
|
|
"""Find a row by column value."""
|
|
wb = get_workbook_readonly(file_path)
|
|
ws = get_sheet(wb, sheet_name)
|
|
headers = get_headers(ws, header_row)
|
|
col_idx = get_column_index(headers, column)
|
|
data_start = header_row + 1
|
|
|
|
for row_num, row in enumerate(ws.iter_rows(min_row=data_start), start=data_start):
|
|
values = [cell_to_str(cell.value) for cell in row]
|
|
if col_idx - 1 < len(values) and values[col_idx - 1] == value:
|
|
row_data = {"_row_num": row_num}
|
|
for i, header in enumerate(headers):
|
|
if i < len(values):
|
|
key = header if header else f"_col_{i + 1}"
|
|
row_data[key] = values[i]
|
|
wb.close()
|
|
return {
|
|
"found": True,
|
|
"row_num": row_num,
|
|
"row": row_data,
|
|
"message": f"Found at row {row_num}",
|
|
}
|
|
|
|
wb.close()
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"No row found with {column}='{value}'",
|
|
)
|
|
|
|
|
|
@app.post("/excel/row")
|
|
async def add_row(request: AddRowRequest):
|
|
"""Add a new row to the sheet."""
|
|
wb, full_path = get_workbook_writable(request.file_path)
|
|
ws = get_sheet(wb, request.sheet_name)
|
|
headers = get_headers(ws, request.header_row)
|
|
|
|
# Find next empty row
|
|
next_row = ws.max_row + 1
|
|
|
|
# Write values
|
|
for col_name, value in request.row.items():
|
|
if col_name.startswith("_"):
|
|
continue
|
|
try:
|
|
col_idx = headers.index(col_name) + 1
|
|
ws.cell(row=next_row, column=col_idx, value=value)
|
|
except ValueError:
|
|
pass # Skip unknown columns
|
|
|
|
wb.save(full_path)
|
|
wb.close()
|
|
|
|
return {
|
|
"success": True,
|
|
"row_num": next_row,
|
|
"message": f"Added row at {next_row}",
|
|
}
|
|
|
|
|
|
@app.put("/excel/row/{row_num}")
|
|
async def update_row(row_num: int, request: UpdateRowRequest):
|
|
"""Update an existing row."""
|
|
if row_num < 1:
|
|
raise HTTPException(status_code=400, detail="Row number must be >= 1")
|
|
|
|
wb, full_path = get_workbook_writable(request.file_path)
|
|
ws = get_sheet(wb, request.sheet_name)
|
|
headers = get_headers(ws, request.header_row)
|
|
|
|
# Check row exists
|
|
if row_num > ws.max_row:
|
|
wb.close()
|
|
raise HTTPException(status_code=404, detail=f"Row {row_num} not found")
|
|
|
|
# Update values
|
|
for col_name, value in request.row.items():
|
|
if col_name.startswith("_"):
|
|
continue
|
|
try:
|
|
col_idx = headers.index(col_name) + 1
|
|
ws.cell(row=row_num, column=col_idx, value=value)
|
|
except ValueError:
|
|
pass # Skip unknown columns
|
|
|
|
wb.save(full_path)
|
|
wb.close()
|
|
|
|
return {
|
|
"success": True,
|
|
"row_num": row_num,
|
|
"message": f"Updated row {row_num}",
|
|
}
|
|
|
|
|
|
@app.post("/excel/create")
|
|
async def create_workbook(request: CreateWorkbookRequest):
|
|
"""Create a new workbook with specified columns."""
|
|
full_path = DATA_ROOT / request.file_path
|
|
|
|
# Create parent directories if needed
|
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Check if file exists
|
|
if full_path.exists():
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"File already exists: {request.file_path}",
|
|
)
|
|
|
|
# Create workbook
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = request.sheet_name
|
|
|
|
# Write headers with formatting
|
|
header_font = Font(bold=True)
|
|
for col_idx, col_name in enumerate(request.columns, start=1):
|
|
cell = ws.cell(row=1, column=col_idx, value=col_name)
|
|
cell.font = header_font
|
|
cell.alignment = Alignment(horizontal="center")
|
|
|
|
# Auto-adjust column widths
|
|
for col_idx, col_name in enumerate(request.columns, start=1):
|
|
ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = max(
|
|
len(col_name) + 2, 12
|
|
)
|
|
|
|
wb.save(full_path)
|
|
wb.close()
|
|
|
|
return {
|
|
"success": True,
|
|
"file_path": request.file_path,
|
|
"sheet_name": request.sheet_name,
|
|
"columns": request.columns,
|
|
"message": f"Created workbook with {len(request.columns)} columns",
|
|
}
|