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}