2026-10-16 09:31:45 -06:00
|
|
|
|
"""Input cleaning and limits: the old build's cleanText, cleanDeep and validation regexes, now on the server.
|
|
|
|
|
|
|
|
|
|
|
|
Every string that enters the database goes through `clean_text` with the field's limit. Control characters
|
|
|
|
|
|
(except tab, LF and CR), DEL, the zero-width and bidi override characters are removed, the text is NFC
|
|
|
|
|
|
normalised, trimmed and cut to the limit.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
import unicodedata
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
# C0 controls except tab (09), LF (0A) and CR (0D); DEL; zero-width and bidi marks; isolates.
|
|
|
|
|
|
_STRIP = re.compile("[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f---]")
|
|
|
|
|
|
|
|
|
|
|
|
RX_HEAT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ./#_-]{0,39}$")
|
|
|
|
|
|
RX_STAMP = re.compile(r"^[A-Za-z0-9-]{1,10}$")
|
|
|
|
|
|
RX_EMP = re.compile(r"^[A-Z0-9-]{1,20}$")
|
|
|
|
|
|
RX_PROJECT = re.compile(r"^[A-Z0-9][A-Z0-9-]{0,19}$")
|
|
|
|
|
|
RX_LOT = re.compile(r"^[A-Z0-9]{1,3}$")
|
|
|
|
|
|
|
|
|
|
|
|
# Character limits per field. Reasons for IT edits and send-backs need at least REASON_MIN.
|
|
|
|
|
|
LIMITS: dict[str, int] = {
|
|
|
|
|
|
"title": 200,
|
|
|
|
|
|
"customer": 120,
|
|
|
|
|
|
"drawing": 60,
|
|
|
|
|
|
"rev": 10,
|
|
|
|
|
|
"wps": 40,
|
|
|
|
|
|
"op": 40,
|
|
|
|
|
|
"notes": 2000,
|
|
|
|
|
|
"reason": 500,
|
|
|
|
|
|
"note": 500,
|
|
|
|
|
|
"heat": 40,
|
|
|
|
|
|
"stamp": 10,
|
|
|
|
|
|
"location": 120,
|
|
|
|
|
|
"part_id": 60,
|
|
|
|
|
|
"description": 240,
|
|
|
|
|
|
"material": 120,
|
|
|
|
|
|
"length": 40,
|
|
|
|
|
|
"project_number": 20,
|
|
|
|
|
|
"project_name": 120,
|
|
|
|
|
|
"po": 60,
|
|
|
|
|
|
"lot": 3,
|
|
|
|
|
|
"unit_name": 120,
|
|
|
|
|
|
"doc_title": 160,
|
|
|
|
|
|
"ecn": 40,
|
|
|
|
|
|
"name": 160,
|
2026-10-16 14:45:38 -06:00
|
|
|
|
"order_pin": 40,
|
|
|
|
|
|
"file_name": 160,
|
|
|
|
|
|
"job_note": 200,
|
2026-10-16 22:46:56 -06:00
|
|
|
|
"filler": 40,
|
|
|
|
|
|
"preheat": 40,
|
|
|
|
|
|
"weld_text": 40,
|
2026-10-17 20:08:53 -06:00
|
|
|
|
"doc_note": 300,
|
|
|
|
|
|
"qc_doc_title": 140,
|
|
|
|
|
|
"qc_doc_rev": 12,
|
|
|
|
|
|
"qc_doc_note": 600,
|
|
|
|
|
|
"ncr": 40,
|
|
|
|
|
|
"itp_field": 120,
|
2026-10-16 09:31:45 -06:00
|
|
|
|
}
|
|
|
|
|
|
REASON_MIN = 8
|
|
|
|
|
|
MAX_BODY_BYTES = 200_000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clean_text(value: Any, max_len: int = 2000) -> str:
|
|
|
|
|
|
"""The old cleanText: strip controls and invisible marks, NFC, trim, cut. None becomes ''."""
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
text = value if isinstance(value, str) else str(value)
|
|
|
|
|
|
text = _STRIP.sub("", text)
|
|
|
|
|
|
text = unicodedata.normalize("NFC", text).strip()
|
|
|
|
|
|
return text[:max_len]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clean_opt(value: Any, max_len: int) -> str | None:
|
|
|
|
|
|
"""clean_text, but an empty result is None (for nullable columns)."""
|
|
|
|
|
|
text = clean_text(value, max_len)
|
|
|
|
|
|
return text or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clean_deep(value: Any, max_len: int = 4000, depth: int = 0) -> Any:
|
|
|
|
|
|
"""Clean every string inside a JSON-like value. Depth is capped at 12 like the old build."""
|
|
|
|
|
|
if depth > 12:
|
|
|
|
|
|
return value
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
return clean_text(value, max_len)
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
return [clean_deep(x, max_len, depth + 1) for x in value]
|
|
|
|
|
|
if isinstance(value, dict):
|
|
|
|
|
|
return {str(k)[:120]: clean_deep(v, max_len, depth + 1) for k, v in value.items()}
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def limit(field: str) -> int:
|
|
|
|
|
|
return LIMITS.get(field, 2000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reason_ok(value: Any) -> str | None:
|
|
|
|
|
|
"""A cleaned reason of at least REASON_MIN characters, else None."""
|
|
|
|
|
|
text = clean_text(value, LIMITS["reason"])
|
|
|
|
|
|
return text if len(text) >= REASON_MIN else None
|