Files
PacketStation/server/packetstation/text.py
T
spencermandClaude Fable 5.1 37d9a0cb50 Phase 2: core domain, policy engine, packet API, Clerical screen
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]>
2026-10-16 09:31:45 -06:00

91 lines
2.8 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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