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:
@@ -13,11 +13,14 @@ All notable changes to this project are recorded here. The format follows [Keep
|
||||
|
||||
### 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.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -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`.
|
||||
- `SMTP_HOST=app-03.pesco.com`, `SMTP_PORT=25`, `SMTP_STARTTLS=true`, `[email protected]`, `SMTP_CA_BUNDLE`.
|
||||
- `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.
|
||||
- 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)".
|
||||
|
||||
+2
-2
@@ -42,14 +42,14 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
|
||||
### Sessions (phase 1)
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 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)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Department, Factor, Me } from "./types";
|
||||
import { useLocation } from "react-router";
|
||||
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;
|
||||
/** A tab hidden for longer than this comes back locked (the old build's rule). */
|
||||
export const HIDDEN_LOCK_MS = 60_000;
|
||||
@@ -26,7 +26,7 @@ function goneNotice(err: ApiError): string {
|
||||
|
||||
interface AuthProviderProps {
|
||||
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;
|
||||
/** How often the idle timer checks. */
|
||||
idleCheckMs?: number;
|
||||
@@ -34,7 +34,7 @@ interface AuthProviderProps {
|
||||
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 [me, setMeState] = useState<Me | null>(null);
|
||||
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(() => {
|
||||
if (status !== "ready") return;
|
||||
lastActivity.current = Date.now();
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface SessionInfo {
|
||||
expires_at: string;
|
||||
locked: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whS
|
||||
import { useWhMove } from "../warehouse/useWhMove";
|
||||
|
||||
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 {
|
||||
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 tabs: Array<{ id: Tab; label: string; count?: number }> = [
|
||||
{ id: "drawing", label: "Drawing", count: drawingPages.length },
|
||||
{ id: "heat", label: "Heat map" },
|
||||
{ id: "materials", label: "Materials", count: p.bom.length },
|
||||
{ id: "heat", label: "Heat map" },
|
||||
{ id: "checklist", label: "Checklist", ...(p.stage ? { count: ckOpen } : {}) },
|
||||
{ id: "welds", label: "Weld map", count: p.welds.length },
|
||||
...(hasQc ? [{ id: "qc" as const, label: "Q.C." }] : []),
|
||||
|
||||
@@ -351,7 +351,7 @@ export function WorkOrdersTab({ p }: { p: PacketRead }) {
|
||||
return (
|
||||
<div key={w.id ?? woKey(w)} className="qr">
|
||||
<Qr text={code} />
|
||||
<div>
|
||||
<div className="txt">
|
||||
<div className="wo">{w.wo_base}</div>
|
||||
<div className="small">
|
||||
Lot <b className="mono">{w.lot}</b> · Seq <b className="mono">{show(w.seq)}</b> {w.op ?? ""}
|
||||
|
||||
@@ -337,11 +337,12 @@ input.in.num { text-align: right; font-family: var(--f-mono); }
|
||||
.act .why { color: var(--amber); }
|
||||
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; }
|
||||
.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 .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 .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-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.pdfv-bar .seg button { min-width: 44px; font-weight: 700; }
|
||||
|
||||
@@ -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(
|
||||
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 ''}",
|
||||
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(
|
||||
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 ''}",
|
||||
tx0,
|
||||
y + 64,
|
||||
size=7.5,
|
||||
color=GREY,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
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'")
|
||||
r = pg_client.get("/api/account")
|
||||
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:
|
||||
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'")
|
||||
r = pg_client.get("/api/auth/me")
|
||||
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"}
|
||||
|
||||
|
||||
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:
|
||||
sign_in(pg_client, "clerk.clara")
|
||||
pg_client.post("/api/auth/lock")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user