"""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 from runtime_support import ensure_stdio, run_with_fatal_log ensure_stdio("warehouse_main") import customtkinter as ctk from tkinter import messagebox from async_loop_singleton import get_global_loop, stop_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 storico_pickinglist import open_storico_pickinglist_window from storico_udc import open_storico_udc_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", } # Create one global loop for database work. Tk must keep the main thread clean; # callers schedule async jobs on this loop explicitly. _loop = get_global_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 _dispose_db_client() -> None: """Best-effort disposal of the shared DB client.""" global db_app if db_app is None: return try: fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop) try: fut.result(timeout=2) except Exception: pass finally: db_app = None def _destroy_tk_root(root: tk.Misc | None) -> None: """Destroy a hidden bootstrap root without leaking a Tk interpreter.""" if root is None: return try: root.quit() except Exception: pass try: root.destroy() except Exception: pass def _shutdown_runtime(*, bootstrap: tk.Misc | None = None, dispose_db: bool = True) -> None: """Release temporary Tk resources, DB client and background loop.""" try: _destroy_tk_root(bootstrap) finally: if dispose_db: _dispose_db_client() try: stop_global_loop() except Exception: pass 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", "storico_udc", "pickinglist", "storico_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._exit_icon = self._make_exit_icon( color=str(theme_value(self._theme, "exit_icon_color", "#ca3d3d")) ) 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_or_focus_child_window( "reset_corsie", lambda: 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_or_focus_child_window( "layout", lambda: 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_or_focus_child_window( "multi_udc", lambda: 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_or_focus_child_window( "search", lambda: open_search_window(self, self.db_client, session=self.session), ), ), ( "storico_udc", loc_text("launcher.history_udc", catalog=self._locale_catalog, default="Storico movimenti UDC"), "launcher.open_history_udc", lambda: self._open_or_focus_child_window( "storico_udc", lambda: open_storico_udc_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_or_focus_child_window( "pickinglist", lambda: open_pickinglist_window(self, self.db_client, session=self.session), ), ), ( "storico_pickinglist", loc_text("launcher.history_pickinglist", catalog=self._locale_catalog, default="Storico Picking List"), "launcher.open_history_pickinglist", lambda: self._open_or_focus_child_window( "storico_pickinglist", lambda: open_storico_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 text = label button_options = {} if _key == "exit": row = 2 column = max_buttons_per_row - 1 text = label button_options = { "image": self._exit_icon, "compound": "left", } button = ctk.CTkButton( wrap, text=text, font=theme_font(self._theme, "button_font", default=("Segoe UI", 11, "bold")), state="normal" if self.session.can(permission) else "disabled", command=callback, **button_options, ) 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 _make_exit_icon(self, *, color: str) -> tk.PhotoImage: """Create a small red X icon without adding image assets to the project.""" size = 14 image = tk.PhotoImage(width=size, height=size) for offset in range(3, 11): image.put(color, (offset, offset)) image.put(color, (offset + 1, offset)) image.put(color, (offset, size - 1 - offset)) image.put(color, (offset + 1, size - 1 - offset)) return image 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( "", lambda event, win=window, win_key=key: self._forget_child_window(win_key, win, event.widget), add="+", ) except Exception: pass try: window.bind( "", lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget), add="+", ) except Exception: pass try: window.bind( "", lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget), add="+", ) except Exception: pass def _open_or_focus_child_window(self, key: str, factory) -> None: """Open one child per launcher key, or focus the existing 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)() } existing = self._child_windows_by_key.get(key) if existing is not None and getattr(existing, "winfo_exists", lambda: False)(): try: if hasattr(existing, "state") and existing.state() == "iconic": existing.deiconify() except Exception: pass try: existing.lift() existing.focus_force() except Exception: pass try: place_window_below_parent_later(self, existing) except Exception: pass return self._open_child_window(key, factory()) 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() def run_app() -> int: """Run the backoffice application entry point.""" 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, ) _destroy_tk_root(root) try: stop_global_loop() except Exception: pass return 0 db_cfg = ensure_db_config(_loop) if db_cfg is None: try: stop_global_loop() except Exception: pass return 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: _shutdown_runtime(bootstrap=bootstrap, dispose_db=True) return 0 _destroy_tk_root(bootstrap) try: Launcher(session, db_app).mainloop() finally: _shutdown_runtime(bootstrap=None, dispose_db=True) return 0 if __name__ == "__main__": raise SystemExit(run_with_fatal_log("Warehouse Backoffice", run_app))