Work-order codes no longer overlap (#42), Materials before Heat map (#44), 60 min idle for Admin and Clerical (#45)
- Traveler: each work-order line shrinks, then clips, to fit its own box; 18 pt gap between boxes - Work orders tab: card text wraps inside the card, smaller work order, wider cards - Packet tabs: Materials before Heat map - Sessions: SESSION_DESK_MINUTES (60) for Admin and Clerical lock and sign-out; /me answers lock_minutes and the browser's idle lock follows it Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -66,6 +66,7 @@ def me_payload(conn: Any, p: Principal) -> dict[str, Any]:
|
||||
"expires_at": s["expires_at"],
|
||||
"locked": s.get("locked_since") is not None,
|
||||
"locked_since": s.get("locked_since"),
|
||||
"lock_minutes": p.lock_minutes,
|
||||
"device": devices.public(p.device),
|
||||
},
|
||||
"pin": pins.state(conn, p.user_id).public(),
|
||||
|
||||
@@ -28,6 +28,18 @@ log = logging.getLogger(__name__)
|
||||
LAST_SEEN_SLACK = timedelta(seconds=30)
|
||||
|
||||
|
||||
def desk_roles(roles: list[str]) -> bool:
|
||||
"""Admin and Clerical get the longer desk idle limits."""
|
||||
return any(r == "admin" or r.startswith("clerical") for r in roles)
|
||||
|
||||
|
||||
def idle_minutes(s: Settings, roles: list[str]) -> tuple[int, int]:
|
||||
"""(sign-out, screen lock) idle minutes for a session with these roles."""
|
||||
if desk_roles(roles):
|
||||
return max(s.session_idle_minutes, s.session_desk_minutes), max(s.session_lock_minutes, s.session_desk_minutes)
|
||||
return s.session_idle_minutes, s.session_lock_minutes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- plumbing
|
||||
|
||||
|
||||
@@ -160,6 +172,7 @@ class Principal:
|
||||
kind: str
|
||||
device: dict[str, Any] | None
|
||||
ip: str | None
|
||||
lock_minutes: int = 3
|
||||
|
||||
@property
|
||||
def session_id(self) -> uuid.UUID:
|
||||
@@ -253,11 +266,12 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
|
||||
raise ApiError(401, "no_session", "Not signed in.")
|
||||
now = row["db_now"]
|
||||
idle = now - row["last_seen_at"]
|
||||
if idle > timedelta(minutes=s.session_idle_minutes):
|
||||
idle_min, lock_min = idle_minutes(s, list((row.get("roles_snapshot") or {}).get("roles", [])))
|
||||
if idle > timedelta(minutes=idle_min):
|
||||
_expire(conn, row, "idle", ip)
|
||||
raise ApiError(401, "session_expired", "Signed out after a period of no activity.")
|
||||
lock_source: str | None = None
|
||||
if row["locked_since"] is None and idle > timedelta(minutes=s.session_lock_minutes):
|
||||
if row["locked_since"] is None and idle > timedelta(minutes=lock_min):
|
||||
lock_source = "idle"
|
||||
hidden_since = row.get("hidden_since")
|
||||
if hidden_since is not None:
|
||||
@@ -308,6 +322,7 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
|
||||
kind=str(row["kind"]),
|
||||
device=device,
|
||||
ip=ip,
|
||||
lock_minutes=idle_minutes(s, roles)[1],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,16 +17,30 @@ from pypdf import PdfReader, PdfWriter
|
||||
from reportlab.graphics import renderPDF
|
||||
from reportlab.graphics.barcode.qr import QrCodeWidget
|
||||
from reportlab.graphics.shapes import Drawing
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
|
||||
from . import parts, stamps
|
||||
from .databook import heats_for
|
||||
from .doc import Doc
|
||||
from .doc import Doc, font_of
|
||||
from .style import CW, GREY, LINE, Built, L, PdfError, R, WhoRef, clean, fmt_d, fmt_dt, fmt_num, site_tz, utc_now
|
||||
|
||||
BLANK = "________"
|
||||
BLANK_LONG = "________________"
|
||||
QR_BOX_H = 96.0
|
||||
QR_SIZE = 84.0
|
||||
QR_GAP = 18.0 # between work-order boxes: a quiet zone so the kiosk scanner reads one code at a time
|
||||
|
||||
|
||||
def fit_line(s: Any, width: float, size: float, *, bold: bool = False) -> tuple[str, float]:
|
||||
"""One line that fits `width` (issue #42): shrink down to 70% of `size`, then cut with "..."."""
|
||||
text, font, z = clean(s), font_of(bold), size
|
||||
while z - 0.5 >= size * 0.7 and stringWidth(text, font, z) > width:
|
||||
z -= 0.5
|
||||
if stringWidth(text, font, z) > width:
|
||||
while len(text) > 1 and stringWidth(text + "...", font, z) > width:
|
||||
text = text[:-1]
|
||||
text += "..."
|
||||
return text, z
|
||||
|
||||
|
||||
def qr_text(wo: Mapping[str, Any]) -> str:
|
||||
@@ -150,32 +164,38 @@ def _draw(conn: Any, settings: Any, p: Mapping[str, Any]) -> Doc:
|
||||
y += 16
|
||||
y = d.section_label("Work orders - scan at the labor kiosk to clock on in Visual", y)
|
||||
wos = p.get("work_orders") or []
|
||||
bw = (CW - 12) / 2
|
||||
bw = (CW - QR_GAP) / 2
|
||||
for i, w in enumerate(wos):
|
||||
col = i % 2
|
||||
if col == 0:
|
||||
y = d.ensure_space(y, QR_BOX_H + 8)
|
||||
x = L + col * (bw + 12)
|
||||
y = d.ensure_space(y, QR_BOX_H + QR_GAP)
|
||||
x = L + col * (bw + QR_GAP)
|
||||
d.rect(x, y, bw, QR_BOX_H, stroke=LINE, width=0.8)
|
||||
draw_qr(d, qr_text(w), x + 6, y + 6, QR_SIZE)
|
||||
tx0 = x + QR_BOX_H + 2
|
||||
d.tx(w.get("wo_base") or "", tx0, y + 20, size=12, bold=True)
|
||||
d.tx(
|
||||
f"Lot {w.get('lot') or ''} Seq {'' if w.get('seq') is None else w.get('seq')} {w.get('op') or ''}",
|
||||
tx0,
|
||||
y + 36,
|
||||
size=8.5,
|
||||
)
|
||||
d.tx(f"Target {fmt_num(w.get('target_hrs'))} h Qty {fmt_num(w.get('qty'))}", tx0, y + 50, size=8.5)
|
||||
d.tx(
|
||||
f"Drawing {w.get('dwg') or p.get('drawing') or ''} Rev {w.get('rev') or p.get('rev') or ''}",
|
||||
tx0,
|
||||
y + 64,
|
||||
size=7.5,
|
||||
color=GREY,
|
||||
)
|
||||
tx0, tw = x + QR_BOX_H + 2, bw - QR_BOX_H - 8 # text stays inside its own box
|
||||
lines: list[tuple[str, float, float, bool, Any]] = [
|
||||
(w.get("wo_base") or "", 20, 12, True, None),
|
||||
(
|
||||
f"Lot {w.get('lot') or ''} Seq {'' if w.get('seq') is None else w.get('seq')} {w.get('op') or ''}",
|
||||
36,
|
||||
8.5,
|
||||
False,
|
||||
None,
|
||||
),
|
||||
(f"Target {fmt_num(w.get('target_hrs'))} h Qty {fmt_num(w.get('qty'))}", 50, 8.5, False, None),
|
||||
(
|
||||
f"Drawing {w.get('dwg') or p.get('drawing') or ''} Rev {w.get('rev') or p.get('rev') or ''}",
|
||||
64,
|
||||
7.5,
|
||||
False,
|
||||
GREY,
|
||||
),
|
||||
]
|
||||
for s, dy, size, bold, color in lines:
|
||||
text, z = fit_line(s, tw, size, bold=bold)
|
||||
d.tx(text, tx0, y + dy, size=z, bold=bold, **({"color": color} if color else {}))
|
||||
if col == 1 or i == len(wos) - 1:
|
||||
y += QR_BOX_H + 8
|
||||
y += QR_BOX_H + QR_GAP
|
||||
if not wos:
|
||||
y = d.note_box("No work orders were read from this packet.", y, True)
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@ class Settings(BaseSettings):
|
||||
|
||||
session_idle_minutes: int = Field(default=15, ge=1)
|
||||
session_lock_minutes: int = Field(default=3, ge=1)
|
||||
# Admin and Clerical sit at their own desks: sign-out and screen lock wait at least this long (issue #45)
|
||||
session_desk_minutes: int = Field(default=60, ge=1)
|
||||
session_hidden_lock_seconds: int = Field(default=60, ge=0)
|
||||
session_password_hours: int = Field(default=12, ge=1)
|
||||
session_pin_hours: int = Field(default=10, ge=1)
|
||||
|
||||
Reference in New Issue
Block a user