"""People's Packet Station profiles: welder stamp, profile active, station limits. Edited one at a time or by a CSV import keyed on the employee ID. Roles never come from here; AD groups decide them. An inactive profile drops out of the welder picker and the worker filter. Employee IDs stay text (leading zeros kept). """ from __future__ import annotations import csv import io import re from collections import Counter from collections.abc import Mapping from typing import Any from .. import audit from ..api.errors import ApiError RX_STAMP = re.compile(r"^[A-Za-z0-9-]{1,12}$") CSV_MAX_BYTES = 1_000_000 CSV_MAX_ROWS = 2000 COLUMNS = ("employee_id", "welder_stamp", "active", "stations") CLEAR = "-" TRUE_WORDS = ("yes", "true", "1") FALSE_WORDS = ("no", "false", "0") # ---------------------------------------------------------------- reading def station_limits(conn: Any, user_ids: list[Any]) -> dict[Any, dict[str, list[int]]]: """user id -> {dept_code: [station ids]}.""" out: dict[Any, dict[str, list[int]]] = {} if not user_ids: return out rows = conn.execute( "SELECT l.user_id, s.dept_code, s.id FROM user_station_limits l JOIN stations s ON s.id = l.station_id" " WHERE l.user_id = ANY(%s) ORDER BY s.dept_code, s.sort, s.id", (user_ids,), ).fetchall() for uid, dept, sid in rows: out.setdefault(uid, {}).setdefault(dept, []).append(int(sid)) return out def profile_of(conn: Any, user_id: Any) -> dict[str, Any] | None: row = conn.execute("SELECT welder_stamp, profile_active FROM users WHERE id = %s", (user_id,)).fetchone() if row is None: return None return { "welder_stamp": row[0], "profile_active": bool(row[1]), "station_limits": station_limits(conn, [user_id]).get(user_id, {}), } def _stamp_owner(conn: Any, stamp: str, user_id: Any) -> str | None: row = conn.execute( "SELECT display_name FROM users WHERE lower(welder_stamp) = lower(%s) AND id <> %s LIMIT 1", (stamp, user_id) ).fetchone() return str(row[0]) if row else None # ---------------------------------------------------------------- one person def _audit(conn: Any, who: Any, user_id: Any, before: dict[str, Any], after: dict[str, Any], source: str) -> None: audit.append( conn, action="admin.profile_updated", user_id=who.user_id, session_id=who.session_id, role_snapshot=who.role_snapshot, subject_type="user", subject_id=user_id, detail={"before": before, "after": after, "source": source}, ip=who.ip, ) def _write(conn: Any, user_id: Any, stamp: Any, active: Any, limits: Mapping[str, list[int]] | None) -> None: """Write the fields given; `stamp` and `active` use ... (Ellipsis) for unchanged. `limits` replaces the named departments' limits; None leaves them.""" if stamp is not ...: conn.execute("UPDATE users SET welder_stamp = %s WHERE id = %s", (stamp, user_id)) if active is not ...: conn.execute("UPDATE users SET profile_active = %s WHERE id = %s", (bool(active), user_id)) for dept, ids in (limits or {}).items(): conn.execute( "DELETE FROM user_station_limits l USING stations s WHERE s.id = l.station_id AND l.user_id = %s" " AND s.dept_code = %s", (user_id, dept), ) for sid in ids: conn.execute("INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)", (user_id, sid)) def update(conn: Any, who: Any, user_id: Any, fields: Mapping[str, Any]) -> dict[str, Any]: """PATCH a profile: welder_stamp (None clears), profile_active, station_limits {dept: [station id]} (a dept key replaces that department's limits; an empty list clears them).""" stamp: Any = ... if "welder_stamp" in fields: raw = fields["welder_stamp"] stamp = None if raw is None or not str(raw).strip() else str(raw).strip() if stamp is not None and not RX_STAMP.match(stamp): raise ApiError(422, "bad_stamp", "A stamp is 1 to 12 letters, digits or dashes.") active: Any = fields["profile_active"] if fields.get("profile_active") is not None else ... limits: dict[str, list[int]] | None = None if fields.get("station_limits") is not None: limits = {} for dept, ids in dict(fields["station_limits"]).items(): ok = {int(r[0]) for r in conn.execute("SELECT id FROM stations WHERE dept_code = %s", (dept,)).fetchall()} if not conn.execute("SELECT 1 FROM departments WHERE code = %s", (dept,)).fetchone(): raise ApiError(422, "unknown_department", f"No department {dept}.") want = sorted({int(x) for x in ids}) bad = [x for x in want if x not in ok] if bad: raise ApiError(422, "unknown_station", f"Station {bad[0]} is not in department {dept}.") limits[str(dept)] = want with conn.transaction(): if conn.execute("SELECT 1 FROM users WHERE id = %s FOR UPDATE", (user_id,)).fetchone() is None: raise ApiError(404, "not_found", "No such user.") if stamp is not ... and stamp is not None: owner = _stamp_owner(conn, stamp, user_id) if owner is not None: raise ApiError(409, "stamp_taken", f"Stamp {stamp} belongs to {owner}.") before = profile_of(conn, user_id) assert before is not None _write(conn, user_id, stamp, active, limits) after = profile_of(conn, user_id) assert after is not None if before != after: _audit(conn, who, user_id, before, after, "edit") return after # ---------------------------------------------------------------- CSV import def _station_index(conn: Any) -> tuple[set[str], dict[tuple[str, str], int], dict[int, str]]: depts = {r[0] for r in conn.execute("SELECT code FROM departments").fetchall()} by_name: dict[tuple[str, str], int] = {} label: dict[int, str] = {} for sid, dept, name in conn.execute("SELECT id, dept_code, name FROM stations").fetchall(): by_name[(dept.lower(), " ".join(name.split()).lower())] = int(sid) label[int(sid)] = f"{dept}:{name}" return depts, by_name, label def _bool(text: str) -> bool | None: t = text.strip().lower() if t in TRUE_WORDS: return True if t in FALSE_WORDS: return False return None def _labels(ids: list[int], label: dict[int, str]) -> list[str]: return sorted(label.get(i, str(i)) for i in ids) def parse_csv(text: str) -> tuple[list[str], list[tuple[int, dict[str, str]]]]: """(header columns, [(line, {column: cell})]). Raises ApiError for a file that cannot be read at all.""" if len(text.encode("utf-8")) > CSV_MAX_BYTES: raise ApiError(413, "too_large", "The file is larger than 1 MB.") reader = csv.reader(io.StringIO(text.lstrip(""))) try: header = next(reader, None) if header is None: raise ApiError(422, "bad_csv", "The file is empty.") cols = [h.strip().lower() for h in header] unknown = [c for c in cols if c and c not in COLUMNS] if "employee_id" not in cols: raise ApiError(422, "bad_csv", "The header row has no employee_id column.") if unknown: raise ApiError(422, "bad_csv", f"Unknown column: {', '.join(unknown)}.") if len(set(c for c in cols if c)) != len([c for c in cols if c]): raise ApiError(422, "bad_csv", "A column is named twice.") rows: list[tuple[int, dict[str, str]]] = [] for cells in reader: if not any(c.strip() for c in cells): continue if len(rows) >= CSV_MAX_ROWS: raise ApiError(422, "bad_csv", f"The file has more than {CSV_MAX_ROWS} rows.") row = {c: (cells[i].strip() if i < len(cells) else "") for i, c in enumerate(cols) if c} rows.append((reader.line_num, row)) except csv.Error as e: raise ApiError(422, "bad_csv", f"The file is not valid CSV: {e}.") from None return cols, rows def import_profiles(conn: Any, who: Any, text: str, *, apply: bool) -> dict[str, Any]: """Preview (apply false) or apply a profile CSV. With any error, apply is refused: 422 with the same body.""" _, parsed = parse_csv(text) depts, by_name, label = _station_index(conn) emps = [r.get("employee_id", "") for _, r in parsed] users: dict[str, list[dict[str, Any]]] = {} want = sorted({e for e in emps if e}) if want: for uid, emp, name, stamp, active in conn.execute( "SELECT id, emp_no, display_name, welder_stamp, profile_active FROM users" " WHERE active AND emp_no = ANY(%s)", (want,), ).fetchall(): users.setdefault(emp, []).append( {"id": uid, "display_name": name, "welder_stamp": stamp, "profile_active": bool(active)} ) limits = station_limits(conn, [u["id"] for us in users.values() for u in us]) seen: Counter[str] = Counter() first_line: dict[str, int] = {} out: list[dict[str, Any]] = [] plans: list[tuple[dict[str, Any], dict[str, Any]]] = [] # (row out, plan) for line, r in parsed: emp = r.get("employee_id", "") errors: list[str] = [] row_out: dict[str, Any] = { "line": line, "employee_id": emp, "user_id": None, "display_name": None, "changes": {}, "errors": errors, } out.append(row_out) if not emp: errors.append("Employee ID is missing.") continue seen[emp] += 1 if seen[emp] > 1: errors.append(f"Employee ID {emp} is repeated in the file (first on line {first_line[emp]}).") continue first_line[emp] = line found = users.get(emp, []) if not found: errors.append(f"Unknown employee ID {emp}.") continue if len(found) > 1: errors.append(f"Employee ID {emp} is on more than one user.") continue u = found[0] row_out["user_id"] = str(u["id"]) row_out["display_name"] = u["display_name"] plan: dict[str, Any] = {"user": u} stamp_cell = r.get("welder_stamp", "") if stamp_cell: new_stamp = None if stamp_cell == CLEAR else stamp_cell if new_stamp is not None and not RX_STAMP.match(new_stamp): errors.append(f"Bad stamp {stamp_cell}: 1 to 12 letters, digits or dashes.") elif new_stamp != u["welder_stamp"]: plan["welder_stamp"] = new_stamp active_cell = r.get("active", "") if active_cell: val = _bool(active_cell) if val is None: errors.append(f"Bad active value {active_cell}: use yes or no.") elif val != u["profile_active"]: plan["profile_active"] = val st_cell = r.get("stations", "") if st_cell: current = sorted(i for ids in limits.get(u["id"], {}).values() for i in ids) ids: list[int] = [] if st_cell != CLEAR: for part in (p.strip() for p in st_cell.split(";")): if not part: continue dept, sep, name = part.partition(":") dept, name = dept.strip(), " ".join(name.split()) if not sep or not dept or not name: errors.append(f"Bad station {part}: write :.") elif dept not in depts: errors.append(f"Unknown department {dept}.") elif (dept.lower(), name.lower()) not in by_name: errors.append(f"Unknown station {name} in department {dept}.") else: sid = by_name[(dept.lower(), name.lower())] if sid not in ids: ids.append(sid) ids.sort() if ids != current: plan["stations"] = (current, ids) plans.append((row_out, plan)) # Stamps must stay unique after the whole file applies. final: dict[Any, str | None] = {} for _, plan in plans: if "welder_stamp" in plan: final[plan["user"]["id"]] = plan["welder_stamp"] claimed = [s for s in final.values() if s] if claimed: others = { str(r[1]).lower(): (r[0], r[2]) for r in conn.execute( "SELECT id, welder_stamp, display_name FROM users WHERE welder_stamp IS NOT NULL" " AND lower(welder_stamp) = ANY(%s)", ([s.lower() for s in claimed],), ).fetchall() } names_by_id = {plan["user"]["id"]: plan["user"]["display_name"] for _, plan in plans} taken_in_file: dict[str, Any] = {} for row_out, plan in plans: s = plan.get("welder_stamp") if not s: continue uid = plan["user"]["id"] holder = others.get(s.lower()) # The holder keeps the stamp unless the same file gives them another (or clears it) freed = holder is not None and holder[0] in final and str(final[holder[0]] or "").lower() != s.lower() if holder is not None and holder[0] != uid and not freed: row_out["errors"].append(f"Stamp {s} is taken by {holder[1]}.") elif s.lower() in taken_in_file: row_out["errors"].append(f"Stamp {s} is taken by {names_by_id[taken_in_file[s.lower()]]}.") else: taken_in_file[s.lower()] = uid for row_out, plan in plans: u = plan["user"] if "welder_stamp" in plan: row_out["changes"]["welder_stamp"] = {"from": u["welder_stamp"], "to": plan["welder_stamp"]} if "profile_active" in plan: row_out["changes"]["profile_active"] = {"from": u["profile_active"], "to": plan["profile_active"]} if "stations" in plan: cur, ids = plan["stations"] row_out["changes"]["station_limits"] = {"from": _labels(cur, label), "to": _labels(ids, label)} n_err = sum(1 for r in out if r["errors"]) body: dict[str, Any] = {"rows": out, "ok": len(out) - n_err, "errors": n_err, "applied": False} if not apply: return body if n_err: raise ApiError( 422, "import_errors", f"{n_err} row{'s' if n_err != 1 else ''} with errors. Nothing was changed.", extra=body, ) changed = 0 with conn.transaction(): for row_out, plan in plans: if not row_out["changes"]: continue uid = plan["user"]["id"] conn.execute("SELECT 1 FROM users WHERE id = %s FOR UPDATE", (uid,)) before = profile_of(conn, uid) stamp = plan.get("welder_stamp", ...) active = plan.get("profile_active", ...) lim: dict[str, list[int]] | None = None if "stations" in plan: conn.execute("DELETE FROM user_station_limits WHERE user_id = %s", (uid,)) for sid in plan["stations"][1]: conn.execute("INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)", (uid, sid)) _write(conn, uid, stamp, active, lim) after = profile_of(conn, uid) assert before is not None and after is not None _audit(conn, who, uid, before, after, "import") changed += 1 audit.append( conn, action="admin.profile_import", user_id=who.user_id, session_id=who.session_id, role_snapshot=who.role_snapshot, subject_type="users", subject_id="import", detail={"rows": len(out), "changed": changed, "unchanged": len(out) - changed}, ip=who.ip, ) body["applied"] = True return body