Migration 0003: projects, aliases, renumbers, units, packets and every child table, change orders, assets, documents, ITPs, station limits, department weld flag. Policy package: Principal, PacketState, Action rules, custody, preconditions, CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept, If-Match row versions, server-assigned numbers, field CAS, version bump, packet log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal. API: /api/packets (header, transitions, work orders, pages, docs, move, remove, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets, /_blob. Frontend: Clerical home, projects and units, packet page with pdf.js and QR codes, hand draft, heat map, send to warehouse, department or Q.C. Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical draft scenario. Verified on docker-1 with the CI stack and the Samba DC. Co-Authored-By: Claude Fable 5.1 <[email protected]>
91 lines
2.8 KiB
Python
91 lines
2.8 KiB
Python
"""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,
|
||
}
|
||
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
|