| """routes_uploads.py β ONE tabular-file preview endpoint (wave 21, item 11 / C5, ruling R10). |
| |
| POST /api/v1/uploads/tabular multipart `file` = .xlsx | .csv, <= 5 MB |
| -> {"columns": [str], "rows": int, "values": {col: [str, ...]}, "truncated": bool} |
| |
| The grid's "Select from file..." dialog uploads a spreadsheet, offers its columns, and matches |
| the chosen column's VALUES against a grid column client-side (exact match after trim+casefold; |
| the matches become the grid selection, which feeds the existing bulk actions). This route's |
| whole job is honest parsing: |
| |
| * FIRST sheet only (xlsx). Silently unioning sheets would "find" rows the user can see are |
| not on the sheet they meant. |
| * Every cell STRINGIFIED + trimmed. Matching is string matching by contract, so the wire |
| carries strings, never spreadsheet cell types. |
| * <= 20,000 data rows, and the cap is DISCLOSED (`truncated: true`), never silent |
| ([[no-unverifiable-aggregates]] β a silently clipped file would report "6 not found" |
| against a tail that was never read). |
| * Fully blank rows are padding, not data β skipped without counting. |
| * The PASTE path never reaches the server at all. |
| |
| β openpyxl is imported INSIDE the handler, deliberately. `verify_no_streamlit` boots the whole |
| API with openpyxl blocked at sys.meta_path β the API must serve every non-upload route without |
| it β and the manifest half of that gate now asserts the package IS declared in both |
| requirements files ([[pin-deps-space-rebuilds]]): absent there, the first upload on a fresh |
| container is a 500. |
| """ |
| import csv |
| import io |
|
|
| from fastapi import APIRouter, Depends, File, UploadFile |
|
|
| from deps import Session, err, require_session |
|
|
| router = APIRouter(prefix="/api/v1") |
|
|
| MAX_BYTES = 5 * 1024 * 1024 |
| MAX_ROWS = 20_000 |
|
|
|
|
| def _cell(v): |
| return "" if v is None else str(v).strip() |
|
|
|
|
| def _headers(row): |
| """Stringified, blank-filled, DEDUPED header names β two 'SKU' columns must not silently |
| merge their values into one list.""" |
| out, seen = [], {} |
| for i, cell in enumerate(row): |
| name = _cell(cell) or f"Column {i + 1}" |
| n = seen.get(name, 0) |
| seen[name] = n + 1 |
| out.append(name if n == 0 else f"{name} ({n + 1})") |
| return out |
|
|
|
|
| @router.post("/uploads/tabular") |
| async def tabular_preview(file: UploadFile = File(...), |
| session: Session = Depends(require_session)): |
| name = (file.filename or "").lower() |
| data = await file.read() |
| if len(data) > MAX_BYTES: |
| raise err(400, "too_large", |
| "that file is over 5 MB β export just the column you need and retry") |
| if name.endswith(".csv"): |
| text = data.decode("utf-8-sig", errors="replace") |
| rows = iter(csv.reader(io.StringIO(text))) |
| elif name.endswith(".xlsx"): |
| try: |
| import openpyxl |
| wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) |
| ws = wb.worksheets[0] |
| except Exception: |
| raise err(400, "bad_file", "that workbook could not be read β is it a real .xlsx?") |
| rows = ws.iter_rows(values_only=True) |
| else: |
| raise err(400, "bad_type", |
| "upload a .xlsx or .csv file (or paste the values into the dialog instead)") |
|
|
| try: |
| first = next(iter(rows)) |
| except StopIteration: |
| raise err(400, "empty_file", "that file has no rows") |
| except Exception: |
| raise err(400, "bad_file", "that file could not be read") |
| columns = _headers(list(first)) |
| values = {c: [] for c in columns} |
| count, truncated = 0, False |
| for row in rows: |
| cells = list(row) |
| if not any(_cell(c) for c in cells): |
| continue |
| if count >= MAX_ROWS: |
| truncated = True |
| break |
| count += 1 |
| for i, col in enumerate(columns): |
| values[col].append(_cell(cells[i]) if i < len(cells) else "") |
| return {"columns": columns, "rows": count, "values": values, "truncated": truncated} |
|
|