252 lines
8.6 KiB
Python
252 lines
8.6 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 gestione_aree import AsyncRunner
|
|
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):
|
|
super().__init__(parent)
|
|
self.db_client = db_client
|
|
self.result_session: UserSession | None = None
|
|
self._async = AsyncRunner(self)
|
|
self._login_button: ttk.Button | None = None
|
|
self._cancel_button: ttk.Button | None = None
|
|
self._status_var = tk.StringVar(value="")
|
|
|
|
self.title("Login Warehouse")
|
|
self.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=12)
|
|
body.pack(fill="both", expand=True)
|
|
body.columnconfigure(1, weight=1)
|
|
|
|
ttk.Label(
|
|
body,
|
|
text="Autenticazione operatore",
|
|
font=("Segoe UI", 11, "bold"),
|
|
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(4, 14))
|
|
|
|
ttk.Label(body, text="Login").grid(row=1, column=0, sticky="w", padx=(0, 10), pady=6)
|
|
self.login_entry = ttk.Entry(body, textvariable=self.login_var, width=28)
|
|
self.login_entry.grid(row=1, column=1, sticky="ew", pady=6)
|
|
|
|
ttk.Label(body, text="Password").grid(row=2, column=0, sticky="w", padx=(0, 10), pady=6)
|
|
self.password_entry = ttk.Entry(body, textvariable=self.password_var, width=28, show="*")
|
|
self.password_entry.grid(row=2, column=1, sticky="ew", pady=6)
|
|
|
|
self.info_label = ttk.Label(
|
|
body,
|
|
text="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="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="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())
|
|
|
|
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)
|
|
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("Login", "Inserisci login e password.", parent=self)
|
|
return
|
|
|
|
params = {"login": login, "password": password}
|
|
self._set_busy(True, "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("Login", "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("Login", f"Verifica credenziali fallita:\n{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
|