Files
ware_house/login_window.py
2026-05-22 14:25:09 +02:00

323 lines
12 KiB
Python

"""Application login dialog backed by the ``Operatori`` table."""
from __future__ import annotations
import tkinter as tk
from tkinter import messagebox, ttk
from typing import Any
from audit_log import log_session_event
from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner
from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_section, theme_value
from user_session import UserSession, create_user_session
SQL_LOGIN = """
SELECT TOP (1)
ID,
Login,
Nominativo,
Privilegio,
CodiceUnita
FROM dbo.Operatori
WHERE LTRIM(RTRIM(Login)) = :login
AND LTRIM(RTRIM([Password])) = :password
ORDER BY ID;
"""
def _rows_to_dicts(res: Any) -> list[dict[str, Any]]:
"""Normalize DB responses into a list of row dictionaries."""
if res is None:
return []
if isinstance(res, list):
return [row for row in res if isinstance(row, dict)]
if isinstance(res, dict):
rows = res.get("rows") or res.get("data") or res.get("records") or []
if rows and isinstance(rows[0], dict):
return rows
cols = res.get("columns") or []
out: list[dict[str, Any]] = []
for row in rows:
if isinstance(row, (list, tuple)) and cols:
out.append({str(cols[i]): row[i] for i in range(min(len(cols), len(row)))})
return out
return []
class LoginWindow(tk.Toplevel):
"""Small modal dialog used to authenticate one warehouse operator."""
def __init__(self, parent: tk.Misc, db_client, *, compact: bool = False):
super().__init__(parent)
self.db_client = db_client
self.compact = bool(compact)
self.result_session: UserSession | None = None
self._async = AsyncRunner(self)
self._theme = theme_section("login_window", {})
self._locale_catalog = load_locale_catalog()
self._login_button: ttk.Button | None = None
self._cancel_button: ttk.Button | None = None
self._status_var = tk.StringVar(value="")
self._busy = InlineBusyOverlay(self, self._theme)
self.title(loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"))
self.geometry("170x145+0+0" if self.compact else str(theme_value(self._theme, "window_geometry", "420x250")))
self.resizable(False, False)
try:
if parent is not None and parent.winfo_viewable():
self.transient(parent)
except Exception:
pass
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
self.login_var = tk.StringVar()
self.password_var = tk.StringVar()
self._build_ui()
self.update_idletasks()
self.grab_set()
self.deiconify()
self.lift()
self.attributes("-topmost", True)
self.after(50, self._show_ready)
def _build_ui(self) -> None:
"""Build the compact operator login form."""
body = ttk.Frame(self, padding=8 if self.compact else 12)
body.pack(fill="both", expand=True)
body.columnconfigure(1, weight=1)
row_offset = 0
if not self.compact:
ttk.Label(
body,
text=loc_text("login.heading", catalog=self._locale_catalog, default="Autenticazione operatore"),
font=("Segoe UI", 11, "bold"),
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(4, 14))
row_offset = 1
ttk.Label(body, text="User:").grid(
row=row_offset, column=0, sticky="w", padx=(0, 6), pady=4 if self.compact else 6
)
self.login_entry = ttk.Entry(body, textvariable=self.login_var, width=10, font=("Segoe UI", 10 if self.compact else 9))
self.login_entry.grid(row=row_offset, column=1, sticky="ew", pady=4 if self.compact else 6)
ttk.Label(body, text="Pwd:").grid(row=row_offset + 1, column=0, sticky="w", padx=(0, 6), pady=4 if self.compact else 6)
self.password_entry = ttk.Entry(body, textvariable=self.password_var, width=10, show="*", font=("Segoe UI", 10 if self.compact else 9))
self.password_entry.grid(row=row_offset + 1, column=1, sticky="ew", pady=4 if self.compact else 6)
if self.compact:
actions = ttk.Frame(body)
actions.grid(row=row_offset + 2, column=0, columnspan=2, sticky="ew", pady=(6, 0))
self._cancel_button = ttk.Button(
actions,
text="Annulla",
command=self._on_cancel,
)
self._cancel_button.grid(row=1, column=0, sticky="ew", pady=(4, 0))
self._login_button = ttk.Button(
actions,
text="OK",
command=self._on_login,
)
self._login_button.grid(row=0, column=0, sticky="ew")
else:
self.info_label = ttk.Label(
body,
text=loc_text(
"login.info",
catalog=self._locale_catalog,
default="Per ora tutti gli operatori autenticati possono usare tutte le funzioni.",
),
justify="left",
wraplength=320,
)
self.info_label.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(10, 8))
self.status_label = ttk.Label(body, textvariable=self._status_var, foreground="#555555")
self.status_label.grid(row=4, column=0, columnspan=2, sticky="w", pady=(2, 2))
actions = ttk.Frame(body)
actions.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(6, 0))
actions.columnconfigure(0, weight=1)
self._cancel_button = ttk.Button(
actions,
text=loc_text("login.button.cancel", catalog=self._locale_catalog, default="Annulla"),
command=self._on_cancel,
)
self._cancel_button.grid(row=0, column=1, padx=(0, 8), pady=8)
self._login_button = ttk.Button(
actions,
text=loc_text("login.button.submit", catalog=self._locale_catalog, default="Accedi"),
command=self._on_login,
)
self._login_button.grid(row=0, column=2, pady=8)
self.bind("<Return>", lambda _e: self._on_login())
self.bind("<Escape>", lambda _e: self._on_cancel())
self.login_var.trace_add("write", lambda *_: self._limit_var(self.login_var, 10))
self.password_var.trace_add("write", lambda *_: self._limit_var(self.password_var, 10))
def _limit_var(self, variable: tk.StringVar, max_len: int) -> None:
value = str(variable.get() or "")
if len(value) > max_len:
variable.set(value[:max_len])
def _set_busy(self, busy: bool, message: str = "") -> None:
"""Enable or disable user interaction during async authentication."""
state = "disabled" if busy else "normal"
try:
self.login_entry.configure(state=state)
self.password_entry.configure(state=state)
if self._login_button is not None:
self._login_button.configure(state=state)
if self._cancel_button is not None:
self._cancel_button.configure(state=state)
self.configure(cursor="watch" if busy else "")
self._status_var.set(message)
if busy:
self._busy.show(message or loc_text("login.status.checking", catalog=self._locale_catalog, default="Verifico credenziali..."))
else:
self._busy.hide()
self.update_idletasks()
except Exception:
pass
def _focus_login(self) -> None:
"""Focus the login entry as soon as the modal becomes visible."""
try:
self.login_entry.focus_force()
except Exception:
pass
def _show_ready(self) -> None:
"""Make the login visible and ready even when the bootstrap root is hidden."""
try:
self.attributes("-topmost", True)
self.deiconify()
self.lift()
self.focus_force()
self._focus_login()
finally:
try:
self.after(250, lambda: self.attributes("-topmost", False))
except Exception:
pass
def _on_login(self) -> None:
"""Validate credentials against the Operatori table."""
login = str(self.login_var.get() or "").strip()
password = str(self.password_var.get() or "").strip()
if not login or not password:
messagebox.showwarning(
loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"),
loc_text("login.msg.missing", catalog=self._locale_catalog, default="Inserisci login e password."),
parent=self,
)
return
params = {"login": login, "password": password}
self._set_busy(True, loc_text("login.status.checking", catalog=self._locale_catalog, default="Verifico credenziali..."))
def _ok(res: Any) -> None:
rows = _rows_to_dicts(res)
self._set_busy(False, "")
if not rows:
log_session_event(
None,
action="login.failed",
outcome="denied",
details={"login": login},
)
messagebox.showerror(
loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"),
loc_text("login.msg.invalid", catalog=self._locale_catalog, default="Credenziali non valide."),
parent=self,
)
try:
self.password_var.set("")
self.password_entry.focus_force()
except Exception:
pass
return
row = rows[0]
self.result_session = create_user_session(
operator_id=int(row.get("ID") or 0),
login=str(row.get("Login") or login).strip(),
nominativo=str(row.get("Nominativo") or "").strip(),
privilegio=int(row["Privilegio"]) if row.get("Privilegio") is not None else None,
codice_unita=str(row.get("CodiceUnita") or "").strip(),
)
log_session_event(
self.result_session,
action="login.success",
outcome="ok",
details={"display_name": self.result_session.display_name},
)
self._close()
def _err(ex: Exception) -> None:
self._set_busy(False, "")
log_session_event(
None,
action="login.error",
outcome="error",
details={"login": login, "error": str(ex)},
)
messagebox.showerror(
loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"),
loc_text("login.msg.error", catalog=self._locale_catalog, default="Verifica credenziali fallita:\n{error}").format(error=ex),
parent=self,
)
self._async.run(
self.db_client.query_json(SQL_LOGIN, params),
_ok,
_err,
)
def _on_cancel(self) -> None:
"""Abort the login flow and close the modal."""
log_session_event(None, action="login.cancel", outcome="cancelled")
self.result_session = None
self._close()
def _close(self) -> None:
"""Release the modal grab and destroy the login window."""
try:
self.grab_release()
except Exception:
pass
try:
self.destroy()
except Exception:
pass
def prompt_login(parent: tk.Misc, db_client) -> UserSession | None:
"""Open the login modal and return the authenticated user session, if any."""
dialog = LoginWindow(parent, db_client)
dialog.wait_window()
return dialog.result_session
def prompt_login_compact(parent: tk.Misc, db_client) -> UserSession | None:
"""Open the barcode-oriented compact login modal."""
dialog = LoginWindow(parent, db_client, compact=True)
dialog.wait_window()
return dialog.result_session