File size: 4,093 Bytes
ef68ae0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | """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 # lazy BY CONTRACT β see the module docstring
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}
|