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

489 lines
18 KiB
Python

"""Application entry point for the warehouse desktop tool.
This module wires together the shared async database client, the global
background event loop and the different Tk/CustomTkinter windows exposed by the
project.
"""
import asyncio
import ctypes
import sys
import tkinter as tk
import time
import customtkinter as ctk
from tkinter import messagebox
from async_loop_singleton import get_global_loop
from async_msssql_query import AsyncMSSQLClient
from audit_log import log_session_event
from db_config import build_dsn_from_config, ensure_db_config
from gestione_layout import open_layout_window
from gestione_pickinglist import open_pickinglist_window
from login_window import prompt_login
from locale_text import load_locale_catalog, text as loc_text
from reset_corsie import open_reset_corsie_window
from search_pallets import open_search_window
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_font, theme_section, theme_value
from user_session import UserSession, create_user_session
from view_celle_multi_udc import open_celle_multiple_window
from window_placement import (
cascade_children_below_parent,
place_window_below_parent_later,
place_window_fullsize_below_parent_later,
)
# Development shortcut: skip the login dialog and boot directly as MAG1.
# Set to False when you want to restore normal authentication.
BYPASS_LOGIN = False
BYPASS_LOGIN_USER = {
"operator_id": 4,
"login": "MAG1",
"nominativo": "MAG1",
"privilegio": 3,
"codice_unita": "U1",
}
if sys.platform.startswith("win"):
try:
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
except Exception:
pass
# Create one global loop and make it the default everywhere.
_loop = get_global_loop()
asyncio.set_event_loop(_loop)
def _noop(*args, **kwargs):
"""Compatibility no-op used when optional Tk DPI hooks are missing."""
return None
if not hasattr(tk.Toplevel, "block_update_dimensions_event"):
tk.Toplevel.block_update_dimensions_event = _noop # type: ignore[attr-defined]
if not hasattr(tk.Toplevel, "unblock_update_dimensions_event"):
tk.Toplevel.unblock_update_dimensions_event = _noop # type: ignore[attr-defined]
db_app: AsyncMSSQLClient | None = None
_APP_MUTEX = None
_APP_MUTEX_NAME = "Local\\WarehousePythonAppSingleton"
def _acquire_single_instance_mutex() -> bool:
"""Return ``True`` only for the first running instance of the application."""
global _APP_MUTEX
if not sys.platform.startswith("win"):
return True
kernel32 = ctypes.windll.kernel32
mutex = kernel32.CreateMutexW(None, False, _APP_MUTEX_NAME)
if not mutex:
return True
last_error = kernel32.GetLastError()
_APP_MUTEX = mutex
ERROR_ALREADY_EXISTS = 183
return last_error != ERROR_ALREADY_EXISTS
def _build_bypass_session() -> UserSession:
"""Create the development session used when authentication is bypassed."""
return create_user_session(
operator_id=int(BYPASS_LOGIN_USER["operator_id"]),
login=str(BYPASS_LOGIN_USER["login"]),
nominativo=str(BYPASS_LOGIN_USER["nominativo"]),
privilegio=int(BYPASS_LOGIN_USER["privilegio"]),
codice_unita=str(BYPASS_LOGIN_USER["codice_unita"]),
)
class Launcher(ctk.CTk):
"""Main launcher window that exposes the available warehouse tools."""
_WINDOW_ORDER = [
"reset_corsie",
"layout",
"multi_udc",
"search",
"pickinglist",
]
def __init__(self, session: UserSession, db_client: AsyncMSSQLClient):
"""Create the launcher toolbar and wire every button to a feature window."""
super().__init__()
self.session: UserSession = session
self.db_client = db_client
self._theme = theme_section("launcher", {})
self._locale_catalog = load_locale_catalog()
self._tooltip_catalog = load_tooltip_catalog()
self._child_windows: list[tk.Misc] = []
self._child_windows_by_key: dict[str, tk.Misc] = {}
self._is_cascading = False
self._focus_restore_pending: set[str] = set()
self._restore_suppressed_until = 0.0
self.title(
f"{loc_text('launcher.window_title', catalog=self._locale_catalog, default='Warehouse 1.0.0')} - {self.session.display_name}"
)
self._apply_dynamic_geometry()
outer_pady = int(theme_value(self._theme, "outer_pady", 10))
outer_padx = int(theme_value(self._theme, "outer_padx", 0))
info_padx = int(theme_value(self._theme, "info_padx", 6))
info_pady = theme_value(self._theme, "info_pady", [4, 2])
button_padx = int(theme_value(self._theme, "button_padx", 6))
button_pady = int(theme_value(self._theme, "button_pady", 6))
max_buttons_per_row = max(1, int(theme_value(self._theme, "max_buttons_per_row", 7)))
wrap = ctk.CTkFrame(self)
wrap.pack(padx=outer_padx, pady=outer_pady, fill="x")
actions = [
(
"reset_corsie",
loc_text("launcher.reset_corsie", catalog=self._locale_catalog, default="Gestione Corsie"),
"launcher.open_reset_corsie",
lambda: self._open_child_window(
"reset_corsie",
open_reset_corsie_window(self, self.db_client, session=self.session),
),
),
(
"layout",
loc_text("launcher.layout", catalog=self._locale_catalog, default="Gestione Layout"),
"launcher.open_layout",
lambda: self._open_child_window(
"layout",
open_layout_window(self, self.db_client, session=self.session),
),
),
(
"multi_udc",
loc_text("launcher.multi_udc", catalog=self._locale_catalog, default="UDC Fantasma"),
"launcher.open_multi_udc",
lambda: self._open_child_window(
"multi_udc",
open_celle_multiple_window(self, self.db_client, session=self.session),
),
),
(
"search",
loc_text("launcher.search", catalog=self._locale_catalog, default="Ricerca UDC"),
"launcher.open_search",
lambda: self._open_child_window(
"search",
open_search_window(self, self.db_client, session=self.session),
),
),
(
"pickinglist",
loc_text("launcher.pickinglist", catalog=self._locale_catalog, default="Gestione Picking List"),
"launcher.open_pickinglist",
lambda: self._open_child_window(
"pickinglist",
open_pickinglist_window(self, self.db_client, session=self.session),
),
),
(
"arrange",
loc_text("launcher.arrange", catalog=self._locale_catalog, default="Ridisponi finestre"),
"launcher.arrange_windows",
self._cascade_open_windows,
),
(
"exit",
loc_text("launcher.exit", catalog=self._locale_catalog, default="Esci"),
"launcher.exit",
self._shutdown,
),
]
used_columns = max(1, min(len(actions), max_buttons_per_row))
info = ctk.CTkLabel(
wrap,
text=loc_text(
"launcher.operator",
catalog=self._locale_catalog,
default="Operatore: {display_name} ({login})",
).format(display_name=self.session.display_name, login=self.session.login),
anchor="w",
font=theme_font(self._theme, "info_font", default=("Segoe UI", 12, "bold")),
)
info.grid(row=0, column=0, columnspan=used_columns, padx=info_padx, pady=tuple(info_pady), sticky="ew")
for idx, (_key, label, permission, callback) in enumerate(actions):
row = 1 + (idx // max_buttons_per_row)
column = idx % max_buttons_per_row
button = ctk.CTkButton(
wrap,
text=label,
font=theme_font(self._theme, "button_font", default=("Segoe UI", 11, "bold")),
state="normal" if self.session.can(permission) else "disabled",
command=callback,
)
button.grid(row=row, column=column, padx=button_padx, pady=button_pady, sticky="ew")
tip = tooltip_text(permission, catalog=self._tooltip_catalog)
if tip:
WidgetToolTip(button, tip)
for i in range(max_buttons_per_row):
wrap.grid_columnconfigure(i, weight=1)
self.update_idletasks()
self._apply_dynamic_geometry()
self.protocol("WM_DELETE_WINDOW", self._shutdown)
try:
self.lift()
self.focus_force()
except Exception:
pass
def _apply_dynamic_geometry(self) -> None:
"""Size the launcher around its current content and keep it docked at the top."""
top_x = int(theme_value(self._theme, "window_top_x", 0))
top_y = int(theme_value(self._theme, "window_top_y", 0))
min_width = int(theme_value(self._theme, "window_min_width", 960))
target_width = int(theme_value(self._theme, "window_width", 1280))
requested_width = max(min_width, self.winfo_reqwidth())
width = max(min_width, target_width, requested_width)
height = max(80, self.winfo_reqheight())
self.geometry(f"{width}x{height}+{top_x}+{top_y}")
def _open_child_window(self, key: str, window: tk.Misc | None) -> None:
"""Track child windows opened from the launcher."""
if window is None:
return
self._child_windows = [w for w in self._child_windows if getattr(w, "winfo_exists", lambda: False)()]
if window not in self._child_windows:
self._child_windows.append(window)
self._child_windows_by_key = {
child_key: child
for child_key, child in self._child_windows_by_key.items()
if getattr(child, "winfo_exists", lambda: False)()
}
self._child_windows_by_key[key] = window
try:
window.bind(
"<Destroy>",
lambda event, win=window, win_key=key: self._forget_child_window(win_key, win, event.widget),
add="+",
)
except Exception:
pass
try:
window.bind(
"<Activate>",
lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget),
add="+",
)
except Exception:
pass
try:
window.bind(
"<FocusIn>",
lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget),
add="+",
)
except Exception:
pass
def _forget_child_window(self, key: str, window: tk.Misc, event_widget: tk.Misc | None = None) -> None:
"""Remove stale references to child windows that have been closed."""
if event_widget is not None and event_widget is not window:
return
try:
tracked = self._child_windows_by_key.get(key)
if tracked is window:
self._child_windows_by_key.pop(key, None)
except Exception:
pass
try:
self._child_windows = [w for w in self._child_windows if w is not window and getattr(w, "winfo_exists", lambda: False)()]
except Exception:
pass
def _widget_belongs_to_window(self, window: tk.Misc, widget: tk.Misc | None) -> bool:
"""Return True when an event widget is the toplevel itself or one of its descendants."""
if widget is None:
return True
if widget is window:
return True
try:
current = widget
while current is not None:
if current is window:
return True
parent_name = current.winfo_parent()
if not parent_name:
break
current = current.nametowidget(parent_name)
except Exception:
pass
return False
def _on_child_activate(self, key: str, window: tk.Misc, event_widget: tk.Misc | None = None) -> None:
"""Restore an activated child to the primary slot below the launcher."""
if self._is_cascading:
return
if time.monotonic() < self._restore_suppressed_until:
return
if event_widget is not None and event_widget is not window:
return
tracked = self._child_windows_by_key.get(key)
if tracked is not window or not getattr(window, "winfo_exists", lambda: False)():
return
if key in self._focus_restore_pending:
return
self._focus_restore_pending.add(key)
def _restore() -> None:
try:
tracked_now = self._child_windows_by_key.get(key)
if tracked_now is window and getattr(window, "winfo_exists", lambda: False)():
place_window_below_parent_later(self, window)
try:
window.lift()
except Exception:
pass
finally:
try:
self.after(250, lambda: self._focus_restore_pending.discard(key))
except Exception:
self._focus_restore_pending.discard(key)
try:
self.after(0, _restore)
except Exception:
self._focus_restore_pending.discard(key)
def _cascade_open_windows(self) -> None:
"""Cascade all open child windows following launcher button order."""
self._child_windows = [w for w in self._child_windows if getattr(w, "winfo_exists", lambda: False)()]
self._child_windows_by_key = {
key: window
for key, window in self._child_windows_by_key.items()
if getattr(window, "winfo_exists", lambda: False)()
}
ordered_windows = [
self._child_windows_by_key[key]
for key in self._WINDOW_ORDER
if key in self._child_windows_by_key
]
self._is_cascading = True
self._focus_restore_pending.clear()
self._restore_suppressed_until = time.monotonic() + 1.2
cascade_children_below_parent(
self,
ordered_windows,
x_offset_step=int(theme_value(self._theme, "cascade_x_offset", 28)),
y_offset_step=int(theme_value(self._theme, "cascade_y_offset", 28)),
margin_left=int(theme_value(self._theme, "cascade_margin_left", 0)),
margin_top=int(theme_value(self._theme, "cascade_margin_top", 0)),
)
def _finish_cascade() -> None:
self._is_cascading = False
self._restore_suppressed_until = 0.0
try:
self.lift()
self.focus_force()
except Exception:
pass
try:
self.after(250, _finish_cascade)
except Exception:
self._is_cascading = False
def _shutdown(self) -> None:
"""Dispose session and shared DB resources before closing the launcher."""
try:
if self.session is not None:
log_session_event(self.session, action="logout", outcome="ok")
if self.db_client is not None:
fut = asyncio.run_coroutine_threadsafe(self.db_client.dispose(), _loop)
try:
fut.result(timeout=2)
except Exception:
pass
finally:
self.destroy()
if __name__ == "__main__":
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("green")
if not _acquire_single_instance_mutex():
root = tk.Tk()
root.withdraw()
messagebox.showwarning(
loc_text("launcher.already_running_title", default="Warehouse"),
loc_text(
"launcher.already_running_message",
default="L'applicazione e' gia' in esecuzione.\nChiudi l'istanza aperta prima di avviarne un'altra.",
),
parent=root,
)
try:
root.destroy()
except Exception:
pass
raise SystemExit(0)
db_cfg = ensure_db_config(_loop)
if db_cfg is None:
raise SystemExit(0)
db_app = AsyncMSSQLClient(build_dsn_from_config(db_cfg))
if BYPASS_LOGIN:
session = _build_bypass_session()
log_session_event(
session,
action="login.bypass",
outcome="ok",
details={"login": session.login},
)
bootstrap = None
else:
bootstrap = tk.Tk()
bootstrap.geometry("1x1+0+0")
bootstrap.overrideredirect(True)
bootstrap.attributes("-alpha", 0.0)
bootstrap.deiconify()
bootstrap.update_idletasks()
session = prompt_login(bootstrap, db_app)
if session is None:
if db_app is not None:
fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop)
try:
fut.result(timeout=2)
except Exception:
pass
try:
if bootstrap is not None:
bootstrap.destroy()
except Exception:
pass
raise SystemExit(0)
try:
if bootstrap is not None:
bootstrap.destroy()
except Exception:
pass
Launcher(session, db_app).mainloop()