Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource map, job tables, order-derived columns with provenance; work order key includes the operation sequence). The real worker on APScheduler with settings re-read each fire, pause, run now, advisory locks, job_runs and a heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache, suspect-batch guard, the apply in one transaction: drafts, change orders, retire-moved, work order gone, closed and cancelled observed, projects and units from order links, auto-release) and the fingerprint. The drawing-lookup job (queue, budget, download on version change, sha256 into assets, decision table, staging for rev approval, drawing-origin change orders). Clerical From VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin Feed settings and resource map. Synthetic fixture generator and a fixture-driven worker in CI with a Playwright scenario. Docs updated. Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI stack, 76 frontend tests, doctor at 0004; one feed and one drawing run against VISUAL DEV and the vault from the developer machine (counts in the changelog). Co-Authored-By: Claude Fable 5.1 <[email protected]>
94 lines
2.9 KiB
Python
94 lines
2.9 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,
|
||
"order_pin": 40,
|
||
"file_name": 160,
|
||
"job_note": 200,
|
||
}
|
||
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
|