Files

103 lines
3.0 KiB
Python
Raw Permalink Normal View History

"""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,
"order_pin": 40,
"file_name": 160,
"job_note": 200,
"filler": 40,
"preheat": 40,
"weld_text": 40,
"doc_note": 300,
"qc_doc_title": 140,
"qc_doc_rev": 12,
"qc_doc_note": 600,
"ncr": 40,
"itp_field": 120,
}
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