Work-order codes no longer overlap (#42), Materials before Heat map (#44), 60 min idle for Admin and Clerical (#45)
ci / frontend (pull_request) Successful in 54s
ci / server (pull_request) Failing after 12m12s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Skipped

- 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:
2026-10-21 13:16:26 -06:00
co-authored by Claude Opus 5.5
parent b0f6a09173
commit de5b361d85
14 changed files with 119 additions and 36 deletions
+3
View File
@@ -13,11 +13,14 @@ All notable changes to this project are recorded here. The format follows [Keep
### Fixed ### Fixed
- Gitea issue 42: work-order text on the traveler stays inside its own box. Each line shrinks down to 70 percent of its size, then is cut with "...", so a long work order, operation or drawing no longer runs over the next QR code. The gap between work-order boxes is 18 pt, a quiet zone for the kiosk scanner. On the Work orders tab the card text wraps inside the card, the work order is 22 px and cards are at least 340 px wide, one per row on a narrow screen.
- Board columns are frames about three cards tall (700 px, never taller than the screen) that scroll on their own, with the column heading staying put, so a long column no longer pushes the next one far down the page. Narrow screens keep the plain one-column flow. - Board columns are frames about three cards tall (700 px, never taller than the screen) that scroll on their own, with the column heading staying put, so a long column no longer pushes the next one far down the page. Narrow screens keep the plain one-column flow.
- Sign-in refused as cross-origin in Firefox: the app sent `Referrer-Policy: no-referrer`, under which Firefox sends `Origin: null` on same-origin POSTs. The policy is now `same-origin`, which still sends nothing to other sites. - Sign-in refused as cross-origin in Firefox: the app sent `Referrer-Policy: no-referrer`, under which Firefox sends `Origin: null` on same-origin POSTs. The policy is now `same-origin`, which still sends nothing to other sites.
### Changed ### Changed
- Gitea issue 44: the packet tabs put Materials before Heat map.
- Gitea issue 45: Admin and Clerical sessions wait 60 idle minutes before the screen lock and before signing out (`SESSION_DESK_MINUTES`, default 60; it never shortens `SESSION_LOCK_MINUTES` or `SESSION_IDLE_MINUTES`). Everyone else keeps 3 and 15. `GET /api/auth/me` answers the session's `lock_minutes` and the browser's idle lock follows it. The hidden-tab lock after a minute is unchanged.
- VISUAL is the source of truth for the drawing rev. When M-Files' Effective rev differs from the rev VISUAL calls but M-Files still holds that rev as Retired (or Effective), the drawing job attaches it to the draft with no flag and no Clerical approval, and the packet page says the drawing is retired in M-Files. Any other M-Files status, or no document at that rev, still flags `rev_mismatch`; Packet Station only ever attaches Effective or Retired drawings. This replaces the "Use rev V as VISUAL calls it" choice from issue 41. Needs MFilesKit 0.1.3 (`DrawingLookup.revision`) and migration 0009. - VISUAL is the source of truth for the drawing rev. When M-Files' Effective rev differs from the rev VISUAL calls but M-Files still holds that rev as Retired (or Effective), the drawing job attaches it to the draft with no flag and no Clerical approval, and the packet page says the drawing is retired in M-Files. Any other M-Files status, or no document at that rev, still flags `rev_mismatch`; Packet Station only ever attaches Effective or Retired drawings. This replaces the "Use rev V as VISUAL calls it" choice from issue 41. Needs MFilesKit 0.1.3 (`DrawingLookup.revision`) and migration 0009.
### Added ### Added
+1 -1
View File
@@ -68,7 +68,7 @@ Key settings:
- `LDAP_URL=ldaps://DC-02.PESCO.COM:636 ldaps://PDC-1.PESCO.COM:636`, `LDAP_BIND_DN=CN=svc-packetstation,OU=System_Accounts,OU=Admin,DC=PESCO,DC=com`, `LDAP_GROUP_BASE_DN=OU=PacketStation,OU=Groups,OU=PESCO_FARMINGTON,DC=PESCO,DC=com`, `LDAP_PASSWORD_FILE`, `LDAP_CA_BUNDLE`. - `LDAP_URL=ldaps://DC-02.PESCO.COM:636 ldaps://PDC-1.PESCO.COM:636`, `LDAP_BIND_DN=CN=svc-packetstation,OU=System_Accounts,OU=Admin,DC=PESCO,DC=com`, `LDAP_GROUP_BASE_DN=OU=PacketStation,OU=Groups,OU=PESCO_FARMINGTON,DC=PESCO,DC=com`, `LDAP_PASSWORD_FILE`, `LDAP_CA_BUNDLE`.
- `SMTP_HOST=app-03.pesco.com`, `SMTP_PORT=25`, `SMTP_STARTTLS=true`, `[email protected]`, `SMTP_CA_BUNDLE`. - `SMTP_HOST=app-03.pesco.com`, `SMTP_PORT=25`, `SMTP_STARTTLS=true`, `[email protected]`, `SMTP_CA_BUNDLE`.
- `APP_ALIAS` per stack. - `APP_ALIAS` per stack.
- Identity timing and limits, all optional with these defaults: `SESSION_IDLE_MINUTES=15`, `SESSION_LOCK_MINUTES=3`, `SESSION_HIDDEN_LOCK_SECONDS=60`, `SESSION_PASSWORD_HOURS=12`, `SESSION_PIN_HOURS=10`, `ROLES_REFRESH_MINUTES=15`, `SIGNIN_ACCOUNT_LIMIT=5`, `SIGNIN_ACCOUNT_WINDOW_MINUTES=5`, `SIGNIN_ACCOUNT_COOLDOWN_MINUTES=10`, `SIGNIN_IP_LIMIT=30`, `SIGNIN_IP_WINDOW_MINUTES=5`, `PIN_MAX_FAILURES=5`, `STEPUP_MAX_FAILURES=3`, `DEVICE_COOKIE_DAYS=365`, `PIN_MIN_DIGITS=6`, `PIN_MAX_DIGITS=12`. - Identity timing and limits, all optional with these defaults: `SESSION_IDLE_MINUTES=15`, `SESSION_LOCK_MINUTES=3`, `SESSION_DESK_MINUTES=60` (Admin and Clerical wait at least this long for both), `SESSION_HIDDEN_LOCK_SECONDS=60`, `SESSION_PASSWORD_HOURS=12`, `SESSION_PIN_HOURS=10`, `ROLES_REFRESH_MINUTES=15`, `SIGNIN_ACCOUNT_LIMIT=5`, `SIGNIN_ACCOUNT_WINDOW_MINUTES=5`, `SIGNIN_ACCOUNT_COOLDOWN_MINUTES=10`, `SIGNIN_IP_LIMIT=30`, `SIGNIN_IP_WINDOW_MINUTES=5`, `PIN_MAX_FAILURES=5`, `STEPUP_MAX_FAILURES=3`, `DEVICE_COOKIE_DAYS=365`, `PIN_MIN_DIGITS=6`, `PIN_MAX_DIGITS=12`.
- Worker sources, all optional: `FEED_SOURCE` (`visualkit`, the default, or `fixture`), `DRAWING_SOURCE` (`mfiles`, the default, or `fixture`) and `FIXTURE_DIR` (default `/fixtures`). A fixture source reads files instead of VISUAL or M-Files (see [FEED.md](FEED.md), "Fixture sources"). `STACK=prod` refuses a fixture source at start. The test and production stacks leave all three unset. Only the CI env file sets them. - Worker sources, all optional: `FEED_SOURCE` (`visualkit`, the default, or `fixture`), `DRAWING_SOURCE` (`mfiles`, the default, or `fixture`) and `FIXTURE_DIR` (default `/fixtures`). A fixture source reads files instead of VISUAL or M-Files (see [FEED.md](FEED.md), "Fixture sources"). `STACK=prod` refuses a fixture source at start. The test and production stacks leave all three unset. Only the CI env file sets them.
- Uploads and storage, all optional: `UPLOAD_MAX_BYTES` (default 25 MB, drawings, MTRs and documents), `UPLOAD_MAX_BYTES_BOM` (default 5 MB, hand BOMs), `ASSETS_QUOTA_WARN_PCT` (default 85, 1 to 99) and `ASSETS_QUOTA_BYTES` (default 0, which means the size of the assets volume; any other value is a byte budget for the stored files). See "Storage quota". - Uploads and storage, all optional: `UPLOAD_MAX_BYTES` (default 25 MB, drawings, MTRs and documents), `UPLOAD_MAX_BYTES_BOM` (default 5 MB, hand BOMs), `ASSETS_QUOTA_WARN_PCT` (default 85, 1 to 99) and `ASSETS_QUOTA_BYTES` (default 0, which means the size of the assets volume; any other value is a byte budget for the stored files). See "Storage quota".
- Virus scan, all optional: `CLAMAV_HOST` (empty by default, which means no scan; `packetstation-clamav` for the profile service), `CLAMAV_PORT` (default 3310) and `CLAMAV_TIMEOUT_SECONDS` (default 30). The env examples carry `#CLAMAV_HOST=packetstation-clamav` commented out. See "Virus scan (ClamAV)". - Virus scan, all optional: `CLAMAV_HOST` (empty by default, which means no scan; `packetstation-clamav` for the profile service), `CLAMAV_PORT` (default 3310) and `CLAMAV_TIMEOUT_SECONDS` (default 30). The env examples carry `#CLAMAV_HOST=packetstation-clamav` commented out. See "Virus scan (ClamAV)".
+2 -2
View File
@@ -42,14 +42,14 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
### Sessions (phase 1) ### Sessions (phase 1)
- HttpOnly, SameSite=Strict session cookie. The session row on the server decides. - HttpOnly, SameSite=Strict session cookie. The session row on the server decides.
- Password sessions: idle sign-out after 15 minutes, absolute 12 hours. PIN shop sessions: absolute 10 hours, idle 15 minutes. All are settings. - Password sessions: idle sign-out after 15 minutes, absolute 12 hours. PIN shop sessions: absolute 10 hours, idle 15 minutes. Admin and Clerical sessions wait 60 idle minutes (issue 45). All are settings.
- Idle and background lock are reported by the browser and enforced by the server. Unlock is a step-up check with the PIN or the domain password. - Idle and background lock are reported by the browser and enforced by the server. Unlock is a step-up check with the PIN or the domain password.
- Origin check on every state-changing request: the `Origin` header (or the `Referer` origin) must match the request host, and `X-Requested-With: PacketStation` must be present. Otherwise 403. - Origin check on every state-changing request: the `Origin` header (or the `Referer` origin) must match the request host, and `X-Requested-With: PacketStation` must be present. Otherwise 403.
- Admin is a password session only. A PIN session cannot open Admin or change Account settings. - Admin is a password session only. A PIN session cannot open Admin or change Account settings.
- Roles are resolved from AD at sign-in (nested groups through `LDAP_MATCHING_RULE_IN_CHAIN`), cached on the session, and re-resolved every 15 minutes on the session and by the AD refresh. A disabled AD account is always refused. A session whose user has lost every role is ended at the next refresh. - Roles are resolved from AD at sign-in (nested groups through `LDAP_MATCHING_RULE_IN_CHAIN`), cached on the session, and re-resolved every 15 minutes on the session and by the AD refresh. A disabled AD account is always refused. A session whose user has lost every role is ended at the next refresh.
- The session cookie token is stored only as an HMAC with the session key. The AD password is used for the bind and discarded. - The session cookie token is stored only as an HMAC with the session key. The AD password is used for the bind and discarded.
- The group-to-role map is derived from the department table and the `PS-` naming convention at startup. No screen edits it, so nobody can promote a group or themselves inside the app. - The group-to-role map is derived from the department table and the `PS-` naming convention at startup. No screen edits it, so nobody can promote a group or themselves inside the app.
- The browser reports hidden tabs and idle time; the server records `locked_since` and refuses a locked session with 423 until a step-up unlock. The server also locks a session itself when no request arrived for the lock interval (3 minutes, as the old build's screen lock), and when a tab stayed hidden for over a minute (the old build's background lock). A reload or an in-app navigation never locks. - The browser reports hidden tabs and idle time; the server records `locked_since` and refuses a locked session with 423 until a step-up unlock. The server also locks a session itself when no request arrived for the lock interval (3 minutes, as the old build's screen lock; 60 for Admin and Clerical, issue 45), and when a tab stayed hidden for over a minute (the old build's background lock). A reload or an in-app navigation never locks.
### PIN (phase 1) ### PIN (phase 1)
+5 -3
View File
@@ -8,7 +8,7 @@ import type { Department, Factor, Me } from "./types";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { screenOf, useEvents } from "./useEvents"; import { screenOf, useEvents } from "./useEvents";
/** The old build's screen lock. The server locks at SESSION_LOCK_MINUTES too; it is the authority. */ /** The old build's screen lock, used until /me says the session's own lock_minutes. The server is the authority. */
export const IDLE_LOCK_MS = 3 * 60_000; export const IDLE_LOCK_MS = 3 * 60_000;
/** A tab hidden for longer than this comes back locked (the old build's rule). */ /** A tab hidden for longer than this comes back locked (the old build's rule). */
export const HIDDEN_LOCK_MS = 60_000; export const HIDDEN_LOCK_MS = 60_000;
@@ -26,7 +26,7 @@ function goneNotice(err: ApiError): string {
interface AuthProviderProps { interface AuthProviderProps {
children: ReactNode; children: ReactNode;
/** Idle time before the local lock. Tests pass a small value. */ /** Idle time before the local lock. Defaults to the session's lock_minutes. Tests pass a small value. */
idleLockMs?: number; idleLockMs?: number;
/** How often the idle timer checks. */ /** How often the idle timer checks. */
idleCheckMs?: number; idleCheckMs?: number;
@@ -34,7 +34,7 @@ interface AuthProviderProps {
hiddenLockMs?: number; hiddenLockMs?: number;
} }
export function AuthProvider({ children, idleLockMs = IDLE_LOCK_MS, idleCheckMs = IDLE_CHECK_MS, hiddenLockMs = HIDDEN_LOCK_MS }: AuthProviderProps) { export function AuthProvider({ children, idleLockMs: idleLockProp, idleCheckMs = IDLE_CHECK_MS, hiddenLockMs = HIDDEN_LOCK_MS }: AuthProviderProps) {
const [status, setStatusState] = useState<AuthStatus>("loading"); const [status, setStatusState] = useState<AuthStatus>("loading");
const [me, setMeState] = useState<Me | null>(null); const [me, setMeState] = useState<Me | null>(null);
const [departments, setDepartments] = useState<Department[]>([]); const [departments, setDepartments] = useState<Department[]>([]);
@@ -193,6 +193,8 @@ export function AuthProvider({ children, idleLockMs = IDLE_LOCK_MS, idleCheckMs
}; };
}, []); }, []);
const sessionLockMin = me?.session.lock_minutes;
const idleLockMs = idleLockProp ?? (sessionLockMin ? sessionLockMin * 60_000 : IDLE_LOCK_MS);
useEffect(() => { useEffect(() => {
if (status !== "ready") return; if (status !== "ready") return;
lastActivity.current = Date.now(); lastActivity.current = Date.now();
+2
View File
@@ -29,6 +29,8 @@ export interface SessionInfo {
expires_at: string; expires_at: string;
locked: boolean; locked: boolean;
locked_since: string | null; locked_since: string | null;
/** Idle minutes before the screen lock: 60 for Admin and Clerical, SESSION_LOCK_MINUTES otherwise. */
lock_minutes?: number;
device: DeviceRef | null; device: DeviceRef | null;
} }
+2 -2
View File
@@ -35,7 +35,7 @@ import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whS
import { useWhMove } from "../warehouse/useWhMove"; import { useWhMove } from "../warehouse/useWhMove";
type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "qc" | "workorders" | "changes" | "docs" | "history"; type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "qc" | "workorders" | "changes" | "docs" | "history";
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "qc", "workorders", "changes", "docs", "history"]; const TABS: readonly Tab[] = ["drawing", "materials", "heat", "checklist", "welds", "qc", "workorders", "changes", "docs", "history"];
function isTab(v: string | null): v is Tab { function isTab(v: string | null): v is Tab {
return v !== null && (TABS as readonly string[]).includes(v); return v !== null && (TABS as readonly string[]).includes(v);
} }
@@ -332,8 +332,8 @@ export function PacketPage() {
const ckOpen = p.checklist_items.filter((i) => !p.stage?.checklist?.[i.key]?.done).length; const ckOpen = p.checklist_items.filter((i) => !p.stage?.checklist?.[i.key]?.done).length;
const tabs: Array<{ id: Tab; label: string; count?: number }> = [ const tabs: Array<{ id: Tab; label: string; count?: number }> = [
{ id: "drawing", label: "Drawing", count: drawingPages.length }, { id: "drawing", label: "Drawing", count: drawingPages.length },
{ id: "heat", label: "Heat map" },
{ id: "materials", label: "Materials", count: p.bom.length }, { id: "materials", label: "Materials", count: p.bom.length },
{ id: "heat", label: "Heat map" },
{ id: "checklist", label: "Checklist", ...(p.stage ? { count: ckOpen } : {}) }, { id: "checklist", label: "Checklist", ...(p.stage ? { count: ckOpen } : {}) },
{ id: "welds", label: "Weld map", count: p.welds.length }, { id: "welds", label: "Weld map", count: p.welds.length },
...(hasQc ? [{ id: "qc" as const, label: "Q.C." }] : []), ...(hasQc ? [{ id: "qc" as const, label: "Q.C." }] : []),
+1 -1
View File
@@ -351,7 +351,7 @@ export function WorkOrdersTab({ p }: { p: PacketRead }) {
return ( return (
<div key={w.id ?? woKey(w)} className="qr"> <div key={w.id ?? woKey(w)} className="qr">
<Qr text={code} /> <Qr text={code} />
<div> <div className="txt">
<div className="wo">{w.wo_base}</div> <div className="wo">{w.wo_base}</div>
<div className="small"> <div className="small">
Lot <b className="mono">{w.lot}</b> · Seq <b className="mono">{show(w.seq)}</b> {w.op ?? ""} Lot <b className="mono">{w.lot}</b> · Seq <b className="mono">{show(w.seq)}</b> {w.op ?? ""}
+3 -2
View File
@@ -337,11 +337,12 @@ input.in.num { text-align: right; font-family: var(--f-mono); }
.act .why { color: var(--amber); } .act .why { color: var(--amber); }
label.btn input.sr { pointer-events: none; } label.btn input.sr { pointer-events: none; }
.balloon { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; border: 1.5px solid var(--cyan); color: var(--cyan); font-family: var(--f-mono); font-size: 12.5px; font-weight: 600; } .balloon { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; border: 1.5px solid var(--cyan); color: var(--cyan); font-family: var(--f-mono); font-size: 12.5px; font-weight: 600; }
.qrs { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; } .qrs { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 18px; }
.qr { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; display: flex; gap: 16px; align-items: center; } .qr { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; display: flex; gap: 16px; align-items: center; }
.qr .code { background: var(--white); padding: 10px; border-radius: 8px; flex: none; width: 148px; height: 148px; display: grid; place-items: center; color: var(--bg); } .qr .code { background: var(--white); padding: 10px; border-radius: 8px; flex: none; width: 148px; height: 148px; display: grid; place-items: center; color: var(--bg); }
.qr .code canvas { width: 128px !important; height: 128px !important; } .qr .code canvas { width: 128px !important; height: 128px !important; }
.qr .wo { font-family: var(--f-disp); font-weight: 700; font-size: 26px; color: var(--white); letter-spacing: .03em; } .qr .txt { min-width: 0; flex: 1; overflow-wrap: anywhere; }
.qr .wo { font-family: var(--f-disp); font-weight: 700; font-size: 22px; color: var(--white); letter-spacing: .03em; line-height: 1.15; }
.pdfv { display: flex; flex-direction: column; gap: 10px; } .pdfv { display: flex; flex-direction: column; gap: 10px; }
.pdfv-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .pdfv-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.pdfv-bar .seg button { min-width: 44px; font-weight: 700; } .pdfv-bar .seg button { min-width: 44px; font-weight: 700; }
+1
View File
@@ -66,6 +66,7 @@ def me_payload(conn: Any, p: Principal) -> dict[str, Any]:
"expires_at": s["expires_at"], "expires_at": s["expires_at"],
"locked": s.get("locked_since") is not None, "locked": s.get("locked_since") is not None,
"locked_since": s.get("locked_since"), "locked_since": s.get("locked_since"),
"lock_minutes": p.lock_minutes,
"device": devices.public(p.device), "device": devices.public(p.device),
}, },
"pin": pins.state(conn, p.user_id).public(), "pin": pins.state(conn, p.user_id).public(),
+17 -2
View File
@@ -28,6 +28,18 @@ log = logging.getLogger(__name__)
LAST_SEEN_SLACK = timedelta(seconds=30) 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 # ---------------------------------------------------------------- plumbing
@@ -160,6 +172,7 @@ class Principal:
kind: str kind: str
device: dict[str, Any] | None device: dict[str, Any] | None
ip: str | None ip: str | None
lock_minutes: int = 3
@property @property
def session_id(self) -> uuid.UUID: 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.") raise ApiError(401, "no_session", "Not signed in.")
now = row["db_now"] now = row["db_now"]
idle = now - row["last_seen_at"] 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) _expire(conn, row, "idle", ip)
raise ApiError(401, "session_expired", "Signed out after a period of no activity.") raise ApiError(401, "session_expired", "Signed out after a period of no activity.")
lock_source: str | None = None 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" lock_source = "idle"
hidden_since = row.get("hidden_since") hidden_since = row.get("hidden_since")
if hidden_since is not None: 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"]), kind=str(row["kind"]),
device=device, device=device,
ip=ip, ip=ip,
lock_minutes=idle_minutes(s, roles)[1],
) )
+41 -21
View File
@@ -17,16 +17,30 @@ from pypdf import PdfReader, PdfWriter
from reportlab.graphics import renderPDF from reportlab.graphics import renderPDF
from reportlab.graphics.barcode.qr import QrCodeWidget from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.graphics.shapes import Drawing from reportlab.graphics.shapes import Drawing
from reportlab.pdfbase.pdfmetrics import stringWidth
from . import parts, stamps from . import parts, stamps
from .databook import heats_for 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 from .style import CW, GREY, LINE, Built, L, PdfError, R, WhoRef, clean, fmt_d, fmt_dt, fmt_num, site_tz, utc_now
BLANK = "________" BLANK = "________"
BLANK_LONG = "________________" BLANK_LONG = "________________"
QR_BOX_H = 96.0 QR_BOX_H = 96.0
QR_SIZE = 84.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: 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 += 16
y = d.section_label("Work orders - scan at the labor kiosk to clock on in Visual", y) y = d.section_label("Work orders - scan at the labor kiosk to clock on in Visual", y)
wos = p.get("work_orders") or [] wos = p.get("work_orders") or []
bw = (CW - 12) / 2 bw = (CW - QR_GAP) / 2
for i, w in enumerate(wos): for i, w in enumerate(wos):
col = i % 2 col = i % 2
if col == 0: if col == 0:
y = d.ensure_space(y, QR_BOX_H + 8) y = d.ensure_space(y, QR_BOX_H + QR_GAP)
x = L + col * (bw + 12) x = L + col * (bw + QR_GAP)
d.rect(x, y, bw, QR_BOX_H, stroke=LINE, width=0.8) 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) draw_qr(d, qr_text(w), x + 6, y + 6, QR_SIZE)
tx0 = x + QR_BOX_H + 2 tx0, tw = x + QR_BOX_H + 2, bw - QR_BOX_H - 8 # text stays inside its own box
d.tx(w.get("wo_base") or "", tx0, y + 20, size=12, bold=True) lines: list[tuple[str, float, float, bool, Any]] = [
d.tx( (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 ''}", (
tx0, f"Lot {w.get('lot') or ''} Seq {'' if w.get('seq') is None else w.get('seq')} {w.get('op') or ''}",
y + 36, 36,
size=8.5, 8.5,
) False,
d.tx(f"Target {fmt_num(w.get('target_hrs'))} h Qty {fmt_num(w.get('qty'))}", tx0, y + 50, size=8.5) None,
d.tx( ),
f"Drawing {w.get('dwg') or p.get('drawing') or ''} Rev {w.get('rev') or p.get('rev') or ''}", (f"Target {fmt_num(w.get('target_hrs'))} h Qty {fmt_num(w.get('qty'))}", 50, 8.5, False, None),
tx0, (
y + 64, f"Drawing {w.get('dwg') or p.get('drawing') or ''} Rev {w.get('rev') or p.get('rev') or ''}",
size=7.5, 64,
color=GREY, 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: if col == 1 or i == len(wos) - 1:
y += QR_BOX_H + 8 y += QR_BOX_H + QR_GAP
if not wos: if not wos:
y = d.note_box("No work orders were read from this packet.", y, True) y = d.note_box("No work orders were read from this packet.", y, True)
+2
View File
@@ -87,6 +87,8 @@ class Settings(BaseSettings):
session_idle_minutes: int = Field(default=15, ge=1) session_idle_minutes: int = Field(default=15, ge=1)
session_lock_minutes: int = Field(default=3, 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_hidden_lock_seconds: int = Field(default=60, ge=0)
session_password_hours: int = Field(default=12, ge=1) session_password_hours: int = Field(default=12, ge=1)
session_pin_hours: int = Field(default=10, ge=1) session_pin_hours: int = Field(default=10, ge=1)
+20 -2
View File
@@ -30,7 +30,7 @@ def test_token_is_stored_only_as_hmac(pg_client: TestClient, pg_settings: Settin
def test_idle_lock_after_lock_minutes(pg_client: TestClient, pg_settings: Settings) -> None: def test_idle_lock_after_lock_minutes(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "clerk.clara") sign_in(pg_client, "wh.wade")
_age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '4 minutes'") _age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '4 minutes'")
r = pg_client.get("/api/account") r = pg_client.get("/api/account")
assert r.status_code == 423 and r.json()["code"] == "locked" assert r.status_code == 423 and r.json()["code"] == "locked"
@@ -60,7 +60,7 @@ def test_hidden_tab_locks_only_after_a_minute(pg_client: TestClient, pg_settings
def test_idle_signout_after_idle_minutes(pg_client: TestClient, pg_settings: Settings) -> None: def test_idle_signout_after_idle_minutes(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "clerk.clara") sign_in(pg_client, "wh.wade")
_age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '16 minutes'") _age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '16 minutes'")
r = pg_client.get("/api/auth/me") r = pg_client.get("/api/auth/me")
assert r.status_code == 401 and r.json()["code"] == "session_expired" assert r.status_code == 401 and r.json()["code"] == "session_expired"
@@ -71,6 +71,24 @@ def test_idle_signout_after_idle_minutes(pg_client: TestClient, pg_settings: Set
assert ended == {"reason": "idle"} assert ended == {"reason": "idle"}
def test_admin_and_clerical_get_desk_minutes(pg_client: TestClient, pg_settings: Settings) -> None:
# Issue #45: Admin and Clerical lock and sign out after 60 idle minutes, not 3 and 15
for login in ("clerk.clara", "clerk.leo", "admin.ann", "multi.mia"):
pg_client.cookies.clear()
sign_in(pg_client, login)
assert pg_client.get("/api/auth/me").json()["session"]["lock_minutes"] == 60
_age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '59 minutes' WHERE revoked_at IS NULL")
assert pg_client.get("/api/account").status_code == 200, login
_age(pg_settings, "UPDATE sessions SET last_seen_at = now() - interval '61 minutes' WHERE revoked_at IS NULL")
r = pg_client.get("/api/auth/me")
assert r.status_code == 401 and r.json()["code"] == "session_expired", login
def test_shop_screens_keep_short_minutes(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "weld.andy")
assert pg_client.get("/api/auth/me").json()["session"]["lock_minutes"] == 3
def test_locked_session_does_not_stay_alive_by_polling(pg_client: TestClient, pg_settings: Settings) -> None: def test_locked_session_does_not_stay_alive_by_polling(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "clerk.clara") sign_in(pg_client, "clerk.clara")
pg_client.post("/api/auth/lock") pg_client.post("/api/auth/lock")
+19
View File
@@ -0,0 +1,19 @@
"""Work-order text on the traveler stays inside its own box (issue #42). No database needed."""
from __future__ import annotations
def test_work_order_text_fits_its_box() -> None:
# Issue #42: long work-order text shrinks, then clips, instead of running over the next QR code
from reportlab.pdfbase.pdfmetrics import stringWidth
from packetstation.pdf.doc import font_of
from packetstation.pdf.traveler import fit_line
assert fit_line("L662001", 145, 12, bold=True) == ("L662001", 12)
for s, size, bold in [
("L662001/1-SUB-99999-EXTRA-LONG", 12, True),
("Lot 12 Seq 40 WELD-STALL-HYDROTEST-LONG-NAME", 8.5, False),
]:
text, z = fit_line(s, 145, size, bold=bold)
assert size * 0.7 <= z <= size and stringWidth(text, font_of(bold), z) <= 145