mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
549 lines
21 KiB
Python
549 lines
21 KiB
Python
"""CSV import/export for the spool inventory (#1576).
|
|
|
|
One module owns the round-trip: the same fixed column schema is used to
|
|
serialise existing spools out and to parse + validate a user-supplied CSV
|
|
back in. Validation reuses the `SpoolCreate` Pydantic model so the CSV path
|
|
and the form path share a single source of truth — anything the form rejects,
|
|
the import rejects too, with the same rules.
|
|
|
|
The import flow is two-phase by design: `parse_and_validate()` never writes.
|
|
The route calls it once for the dry-run preview (so the user sees per-row
|
|
valid/error/skipped before committing) and again on confirm, then persists
|
|
only the rows that came back `valid`.
|
|
"""
|
|
|
|
import csv
|
|
import io
|
|
from datetime import datetime
|
|
|
|
from pydantic import BaseModel, ValidationError
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.models.color_catalog import ColorCatalogEntry
|
|
from backend.app.models.spool import Spool
|
|
from backend.app.schemas.spool import SpoolCreate
|
|
|
|
# Fixed CSV header, in output order. Round-trips cleanly: export writes these
|
|
# columns, import expects them. `material` is the only required field; the rest
|
|
# are optional. Keep aligned with the SpoolCreate fields referenced below.
|
|
#
|
|
# `remaining` is a derived, export-only column (= label_weight - weight_used).
|
|
# It's written out for human readability and round-trip clarity, but ignored on
|
|
# import — `weight_used` is the source of truth, and accepting both would let
|
|
# them contradict. `last_used` is a timestamp the model carries but SpoolCreate
|
|
# does not, so import applies it to the ORM object directly (see persist path).
|
|
# `storage_location`, `category` and `low_stock_threshold_pct` are SpoolCreate
|
|
# fields included so a round-trip preserves them (they'd otherwise be lost).
|
|
CSV_COLUMNS = [
|
|
"material",
|
|
"brand",
|
|
"subtype",
|
|
"color_name",
|
|
"rgba",
|
|
"extra_colors",
|
|
"effect_type",
|
|
"label_weight",
|
|
"weight_used",
|
|
"remaining",
|
|
"cost_per_kg",
|
|
"nozzle_temp_min",
|
|
"nozzle_temp_max",
|
|
"last_used",
|
|
"note",
|
|
"storage_location",
|
|
"category",
|
|
"low_stock_threshold_pct",
|
|
]
|
|
|
|
# Upload ceiling for the import endpoint. A spool inventory CSV is a few KB
|
|
# even with thousands of rows; 5 MB is a generous cap that still refuses an
|
|
# OOM-sized body before it's read into memory.
|
|
MAX_CSV_IMPORT_BYTES = 5 * 1024 * 1024
|
|
|
|
# Spreadsheet formula-injection guard. A cell whose first character is one of
|
|
# these is treated as a formula by Excel / LibreOffice / Sheets; we prefix it
|
|
# with a single quote on export so the value renders as literal text.
|
|
_FORMULA_INJECTION_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
|
|
|
# Columns whose CSV cell must be coerced to a number before SpoolCreate sees it.
|
|
# DictReader hands us strings; SpoolCreate wants int/float. Empty cell → omit
|
|
# the field (falls back to the schema default / None).
|
|
_INT_COLUMNS = {"label_weight", "nozzle_temp_min", "nozzle_temp_max", "low_stock_threshold_pct"}
|
|
_FLOAT_COLUMNS = {"cost_per_kg", "weight_used"}
|
|
|
|
# label_weight default, pulled from the schema so the weight_used bounds check
|
|
# stays in sync if the schema default ever changes.
|
|
_DEFAULT_LABEL_WEIGHT = SpoolCreate.model_fields["label_weight"].default
|
|
|
|
|
|
class ImportRowResult(BaseModel):
|
|
"""Per-row outcome of a parse+validate pass.
|
|
|
|
`spool` carries the validated, SpoolCreate-shaped dict for `valid` rows so
|
|
the route can persist without re-parsing. `resolved_color` flags rows whose
|
|
rgba/extra_colors/effect_type were filled in from the Color Catalog rather
|
|
than supplied in the CSV — surfaced in the preview so the user knows a
|
|
colour was inferred.
|
|
"""
|
|
|
|
row_number: int # 1-based data row (header is not counted)
|
|
status: str # "valid" | "error" | "skipped"
|
|
reason: str | None = None
|
|
material: str | None = None
|
|
brand: str | None = None
|
|
color_name: str | None = None
|
|
rgba: str | None = None
|
|
resolved_color: bool = False
|
|
# True when the colour was resolved from a catalog entry of a DIFFERENT
|
|
# material (no exact material match existed). Surfaced so the preview can
|
|
# warn the user the colour came from another material's variant.
|
|
cross_material_color: bool = False
|
|
# True when an active spool with the same material+brand+color_name already
|
|
# exists. Informational only — the import still creates the row (there's no
|
|
# unique constraint); the preview warns so a double-click / re-upload of the
|
|
# same CSV doesn't silently duplicate the inventory.
|
|
duplicate_of_existing: bool = False
|
|
spool: dict | None = None
|
|
|
|
|
|
class ImportPreview(BaseModel):
|
|
"""Result of a dry-run (or the pre-write pass of a real import)."""
|
|
|
|
columns: list[str]
|
|
total: int
|
|
valid_count: int
|
|
error_count: int
|
|
skipped_count: int
|
|
rows: list[ImportRowResult]
|
|
warnings: list[str] = []
|
|
|
|
|
|
class ImportResult(BaseModel):
|
|
"""Summary returned after a real (non-dry-run) import."""
|
|
|
|
created: int
|
|
skipped: int
|
|
errors: int
|
|
error_rows: list[ImportRowResult] = []
|
|
|
|
|
|
def _normalize_header(name: str) -> str:
|
|
"""Map a CSV header cell to a canonical field name.
|
|
|
|
Case- and space-tolerant: "Color Name", "color-name", " COLOR_NAME "
|
|
all collapse to "color_name".
|
|
"""
|
|
return name.strip().lower().replace(" ", "_").replace("-", "_")
|
|
|
|
|
|
def _normalize_rgba(value: str) -> str | None:
|
|
"""Coerce a user-supplied colour cell to 8-char RRGGBBAA hex, or None.
|
|
|
|
Accepts an optional leading `#` and a 6-char RRGGBB form (alpha defaults to
|
|
`ff`). Returns None if the value isn't valid hex of length 6 or 8 — the
|
|
caller turns that into a row error so it isn't silently dropped.
|
|
"""
|
|
raw = value.strip().lstrip("#")
|
|
if len(raw) not in (6, 8):
|
|
return None
|
|
try:
|
|
int(raw, 16)
|
|
except ValueError:
|
|
return None
|
|
if len(raw) == 6:
|
|
raw += "ff"
|
|
return raw.lower()
|
|
|
|
|
|
def _parse_datetime(value: str) -> datetime | None:
|
|
"""Parse an ISO-8601 timestamp, or None if it isn't valid.
|
|
|
|
Accepts what `datetime.isoformat()` produces (what export writes) plus a
|
|
trailing 'Z' for UTC, which `fromisoformat` rejects before Python 3.11.
|
|
"""
|
|
raw = value.strip()
|
|
if not raw:
|
|
return None
|
|
if raw.endswith("Z"):
|
|
raw = raw[:-1] + "+00:00"
|
|
try:
|
|
return datetime.fromisoformat(raw)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
async def _load_color_catalog(db: AsyncSession) -> list[ColorCatalogEntry]:
|
|
"""Load the whole Color Catalog once so per-row resolution is in-memory.
|
|
|
|
A CSV can hold hundreds of rows; resolving each with its own SELECT would
|
|
be an N+1 against a small, rarely-changing table. We pull it once here and
|
|
let `_resolve_color` match against the list.
|
|
"""
|
|
result = await db.execute(select(ColorCatalogEntry))
|
|
return list(result.scalars().all())
|
|
|
|
|
|
def _spool_key(material: str | None, brand: str | None, color_name: str | None) -> tuple[str, str, str]:
|
|
"""Case/space-insensitive identity used for the duplicate soft-warn."""
|
|
return (
|
|
(material or "").strip().lower(),
|
|
(brand or "").strip().lower(),
|
|
(color_name or "").strip().lower(),
|
|
)
|
|
|
|
|
|
async def _load_existing_spool_keys(db: AsyncSession) -> set[tuple[str, str, str]]:
|
|
"""Load material+brand+color_name keys of active spools for the dup warning.
|
|
|
|
Spool has no unique constraint, so a double-click or re-upload of the same
|
|
CSV would silently duplicate the inventory. We pull the active spools' keys
|
|
once and let the preview flag matching rows — informational only, the import
|
|
still creates them.
|
|
"""
|
|
result = await db.execute(select(Spool.material, Spool.brand, Spool.color_name).where(Spool.archived_at.is_(None)))
|
|
return {_spool_key(m, b, c) for m, b, c in result.all()}
|
|
|
|
|
|
def _resolve_color(
|
|
catalog: list[ColorCatalogEntry], brand: str | None, color_name: str | None, material: str | None
|
|
) -> tuple[str, str | None, str | None, bool] | None:
|
|
"""Match brand + color_name against the preloaded catalog (case-insensitive).
|
|
|
|
Returns (rgba, extra_colors, effect_type, cross_material) on a match, else
|
|
None. Prefers an entry whose material matches the row; a catalog entry with
|
|
a NULL material is the project's "matches any material" convention and counts
|
|
as an exact match too. Only when neither exists does it fall back to another
|
|
material's entry and set cross_material=True so the caller can warn that the
|
|
colour came from a different material's variant.
|
|
"""
|
|
if not brand or not color_name:
|
|
return None
|
|
|
|
brand_l = brand.strip().lower()
|
|
name_l = color_name.strip().lower()
|
|
material_l = material.strip().lower() if material else None
|
|
|
|
matches = [
|
|
entry
|
|
for entry in catalog
|
|
if entry.hex_color and entry.manufacturer.lower() == brand_l and entry.color_name.lower() == name_l
|
|
]
|
|
if not matches:
|
|
return None
|
|
|
|
exact = next(
|
|
(e for e in matches if e.material is None or (material_l and e.material.lower() == material_l)),
|
|
None,
|
|
)
|
|
row = exact or matches[0]
|
|
cross_material = exact is None
|
|
|
|
rgba = _normalize_rgba(row.hex_color)
|
|
if rgba is None:
|
|
return None
|
|
return rgba, row.extra_colors, row.effect_type, cross_material
|
|
|
|
|
|
def _readable_validation_error(exc: ValidationError) -> str:
|
|
"""Flatten a Pydantic ValidationError into one short, user-facing line."""
|
|
parts = []
|
|
for err in exc.errors():
|
|
loc = ".".join(str(p) for p in err.get("loc", ())) or "value"
|
|
parts.append(f"{loc}: {err.get('msg', 'invalid')}")
|
|
return "; ".join(parts)
|
|
|
|
|
|
def _empty_preview(warnings: list[str]) -> ImportPreview:
|
|
"""A preview with no rows — used for the early-exit cases (bad/empty file)."""
|
|
return ImportPreview(
|
|
columns=CSV_COLUMNS,
|
|
total=0,
|
|
valid_count=0,
|
|
error_count=0,
|
|
skipped_count=0,
|
|
rows=[],
|
|
warnings=warnings,
|
|
)
|
|
|
|
|
|
async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPreview:
|
|
"""Parse a CSV blob, validate + colour-resolve each row. Never writes.
|
|
|
|
Decodes UTF-8 (BOM tolerant), reads with DictReader against the fixed
|
|
schema, and classifies each row as valid / error / skipped. Valid rows
|
|
carry a SpoolCreate-shaped `spool` dict ready to persist.
|
|
"""
|
|
warnings: list[str] = []
|
|
|
|
try:
|
|
text = raw_bytes.decode("utf-8-sig")
|
|
except UnicodeDecodeError:
|
|
return _empty_preview(["File is not valid UTF-8 text."])
|
|
|
|
reader = csv.reader(io.StringIO(text))
|
|
try:
|
|
header = next(reader)
|
|
except StopIteration:
|
|
return _empty_preview(["CSV is empty."])
|
|
|
|
norm_header = [_normalize_header(h) for h in header]
|
|
known = set(CSV_COLUMNS)
|
|
unknown = [h for h in norm_header if h and h not in known]
|
|
if unknown:
|
|
warnings.append(f"Ignoring unknown columns: {', '.join(unknown)}")
|
|
# Map canonical field name → column index in this file (first occurrence).
|
|
col_index: dict[str, int] = {}
|
|
for idx, h in enumerate(norm_header):
|
|
if h in known and h not in col_index:
|
|
col_index[h] = idx
|
|
|
|
if "material" not in col_index:
|
|
return _empty_preview(warnings + ["Required column 'material' is missing from the header."])
|
|
|
|
# Pull the catalog and the existing-spool keys once; per-row colour
|
|
# resolution and the duplicate soft-warn both match in memory rather than
|
|
# issuing a SELECT per row.
|
|
catalog = await _load_color_catalog(db)
|
|
existing_keys = await _load_existing_spool_keys(db)
|
|
|
|
def cell(row: list[str], field: str) -> str:
|
|
idx = col_index.get(field)
|
|
if idx is None or idx >= len(row):
|
|
return ""
|
|
# Strip whitespace, then undo any export-side formula-injection quoting
|
|
# so export → import round-trips without accumulating a leading quote.
|
|
return _desanitize_cell(row[idx].strip())
|
|
|
|
rows: list[ImportRowResult] = []
|
|
valid = error = skipped = 0
|
|
|
|
for row_number, raw_row in enumerate(reader, start=1):
|
|
# Fully blank row (no non-empty cell) → skip silently.
|
|
if not any(c.strip() for c in raw_row):
|
|
rows.append(ImportRowResult(row_number=row_number, status="skipped", reason="Empty row"))
|
|
skipped += 1
|
|
continue
|
|
|
|
material = cell(raw_row, "material")
|
|
brand = cell(raw_row, "brand") or None
|
|
color_name = cell(raw_row, "color_name") or None
|
|
|
|
if not material:
|
|
rows.append(
|
|
ImportRowResult(
|
|
row_number=row_number,
|
|
status="error",
|
|
reason="material is required",
|
|
brand=brand,
|
|
color_name=color_name,
|
|
)
|
|
)
|
|
error += 1
|
|
continue
|
|
|
|
data: dict = {"material": material}
|
|
if brand:
|
|
data["brand"] = brand
|
|
if color_name:
|
|
data["color_name"] = color_name
|
|
|
|
row_error: str | None = None
|
|
|
|
# Plain text passthrough columns.
|
|
for field in ("subtype", "effect_type", "extra_colors", "note", "storage_location", "category"):
|
|
value = cell(raw_row, field)
|
|
if value:
|
|
data[field] = value
|
|
|
|
# Numeric columns: parse only if present, else leave to schema defaults.
|
|
for field in _INT_COLUMNS:
|
|
value = cell(raw_row, field)
|
|
if value:
|
|
try:
|
|
data[field] = int(value)
|
|
except ValueError:
|
|
row_error = f"{field} must be a whole number (got '{value}')"
|
|
break
|
|
if row_error is None:
|
|
for field in _FLOAT_COLUMNS:
|
|
value = cell(raw_row, field)
|
|
if value:
|
|
try:
|
|
data[field] = float(value)
|
|
except ValueError:
|
|
row_error = f"{field} must be a number (got '{value}')"
|
|
break
|
|
|
|
# Bounds check: weight_used must be within [0, label_weight]. The schema
|
|
# accepts any float, so a negative or over-full value would otherwise be
|
|
# imported silently. label_weight falls back to the schema default when
|
|
# the CSV omits it.
|
|
if row_error is None and "weight_used" in data:
|
|
used = data["weight_used"]
|
|
label = data.get("label_weight", _DEFAULT_LABEL_WEIGHT)
|
|
if used < 0:
|
|
row_error = f"weight_used cannot be negative (got {used})"
|
|
elif used > label:
|
|
row_error = f"weight_used ({used}) exceeds label_weight ({label})"
|
|
|
|
# `last_used` is an ORM-only timestamp (not on SpoolCreate); parse it
|
|
# here and apply it to the validated dict after the SpoolCreate gate.
|
|
last_used: datetime | None = None
|
|
if row_error is None:
|
|
last_used_cell = cell(raw_row, "last_used")
|
|
if last_used_cell:
|
|
last_used = _parse_datetime(last_used_cell)
|
|
if last_used is None:
|
|
row_error = f"last_used must be an ISO date/time (got '{last_used_cell}')"
|
|
|
|
resolved_color = False
|
|
cross_material_color = False
|
|
if row_error is None:
|
|
# Colour precedence: explicit rgba wins; else resolve brand+name
|
|
# from the catalog; else leave blank.
|
|
rgba_cell = cell(raw_row, "rgba")
|
|
if rgba_cell:
|
|
normalized = _normalize_rgba(rgba_cell)
|
|
if normalized is None:
|
|
row_error = f"rgba must be 6- or 8-char hex (got '{rgba_cell}')"
|
|
else:
|
|
data["rgba"] = normalized
|
|
else:
|
|
resolved = _resolve_color(catalog, brand, color_name, material)
|
|
if resolved is not None:
|
|
rgba_val, extra_val, effect_val, cross_material_color = resolved
|
|
data["rgba"] = rgba_val
|
|
# CSV-supplied extra_colors/effect_type take precedence over
|
|
# the catalog's; only fill from catalog when absent.
|
|
if extra_val and "extra_colors" not in data:
|
|
data["extra_colors"] = extra_val
|
|
if effect_val and "effect_type" not in data:
|
|
data["effect_type"] = effect_val
|
|
resolved_color = True
|
|
|
|
if row_error is not None:
|
|
rows.append(
|
|
ImportRowResult(
|
|
row_number=row_number,
|
|
status="error",
|
|
reason=row_error,
|
|
material=material,
|
|
brand=brand,
|
|
color_name=color_name,
|
|
)
|
|
)
|
|
error += 1
|
|
continue
|
|
|
|
# Final gate: SpoolCreate runs the same validators the form uses
|
|
# (rgba pattern, extra_colors/effect_type normalisation, bounds).
|
|
try:
|
|
spool = SpoolCreate(**data)
|
|
except ValidationError as exc:
|
|
rows.append(
|
|
ImportRowResult(
|
|
row_number=row_number,
|
|
status="error",
|
|
reason=_readable_validation_error(exc),
|
|
material=material,
|
|
brand=brand,
|
|
color_name=color_name,
|
|
)
|
|
)
|
|
error += 1
|
|
continue
|
|
|
|
spool_data = spool.model_dump()
|
|
if last_used is not None:
|
|
# last_used isn't a SpoolCreate field; graft it onto the persisted
|
|
# dict so the ORM object carries it.
|
|
spool_data["last_used"] = last_used
|
|
|
|
rows.append(
|
|
ImportRowResult(
|
|
row_number=row_number,
|
|
status="valid",
|
|
material=material,
|
|
brand=brand,
|
|
color_name=color_name,
|
|
rgba=spool.rgba,
|
|
resolved_color=resolved_color,
|
|
cross_material_color=cross_material_color,
|
|
duplicate_of_existing=_spool_key(material, brand, color_name) in existing_keys,
|
|
spool=spool_data,
|
|
)
|
|
)
|
|
valid += 1
|
|
|
|
return ImportPreview(
|
|
columns=CSV_COLUMNS,
|
|
total=valid + error + skipped,
|
|
valid_count=valid,
|
|
error_count=error,
|
|
skipped_count=skipped,
|
|
rows=rows,
|
|
warnings=warnings,
|
|
)
|
|
|
|
|
|
def serialize(spools: list[Spool]) -> bytes:
|
|
"""Render spools to CSV bytes using the fixed schema (export side).
|
|
|
|
rgba is written without a leading `#`, matching the import-side
|
|
normalisation, so export → import round-trips without transformation.
|
|
`remaining` is derived (label_weight - weight_used) and `last_used` is
|
|
written as ISO-8601; empty/None fields become empty cells.
|
|
"""
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(CSV_COLUMNS)
|
|
for spool in spools:
|
|
writer.writerow([_sanitize_cell(_cell_value(spool, col)) for col in CSV_COLUMNS])
|
|
return output.getvalue().encode("utf-8")
|
|
|
|
|
|
def _sanitize_cell(value: str) -> str:
|
|
"""Neutralise spreadsheet formula injection.
|
|
|
|
A free-text field (note, color_name) starting with =, +, -, @, tab, or CR
|
|
is evaluated as a formula by Excel/Sheets/LibreOffice when the CSV is
|
|
opened. Prefixing with a single quote forces it to render as literal text.
|
|
`_desanitize_cell` is the exact inverse, applied on import.
|
|
"""
|
|
if value and value[0] in _FORMULA_INJECTION_PREFIXES:
|
|
return "'" + value
|
|
return value
|
|
|
|
|
|
def _desanitize_cell(value: str) -> str:
|
|
"""Undo `_sanitize_cell` on import so the round-trip is lossless.
|
|
|
|
Export prefixes formula-looking cells with a single quote; strip exactly
|
|
that quote back off when the next character is one of the guarded prefixes,
|
|
so `'=SUM(A1)` reads back as `=SUM(A1)` and the value doesn't accumulate a
|
|
leading quote on every export→import cycle. A quote followed by anything
|
|
else is left untouched — only the prefix `_sanitize_cell` could have added
|
|
is removed.
|
|
"""
|
|
if len(value) >= 2 and value[0] == "'" and value[1] in _FORMULA_INJECTION_PREFIXES:
|
|
return value[1:]
|
|
return value
|
|
|
|
|
|
def _cell_value(spool: Spool, col: str) -> str:
|
|
"""Render one spool field for export. Handles the derived `remaining`
|
|
column and ISO-formats `last_used`; everything else is str() of the value."""
|
|
if col == "remaining":
|
|
# Derived for display: label_weight - weight_used, clamped at 0.
|
|
return str(max(0, round((spool.label_weight or 0) - (spool.weight_used or 0))))
|
|
value = getattr(spool, col, None)
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
# Whole-number floats (weight_used, cost_per_kg) export as ints — "300",
|
|
# not "300.0" — for a cleaner, human-friendly CSV. import re-parses fine.
|
|
if isinstance(value, float) and value.is_integer():
|
|
return str(int(value))
|
|
return str(value)
|