"""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, make_mssql_dsn from audit_log import log_session_event from gestione_layout import open_layout_window from gestione_pickinglist import open_pickinglist_window from login_window import prompt_login 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_fullsize_below_parent_later # ---- Config ---- SERVER = r"mde3\gesterp" DBNAME = "Mediseawall" USER = "sa" PASSWORD = "1Password1" # Development shortcut: skip the login dialog and boot directly as MAG1. # Set to False when you want to restore normal authentication. BYPASS_LOGIN = True 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] dsn_app = make_mssql_dsn(server=SERVER, database=DBNAME, user=USER, password=PASSWORD) db_app = AsyncMSSQLClient(dsn_app) _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): """Create the launcher toolbar and wire every button to a feature window.""" super().__init__() self.session: UserSession = session self._theme = theme_section("launcher", {}) 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"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", "Gestione Corsie", "launcher.open_reset_corsie", lambda: self._open_child_window( "reset_corsie", open_reset_corsie_window(self, db_app, session=self.session), ), ), ( "layout", "Gestione Layout", "launcher.open_layout", lambda: self._open_child_window( "layout", open_layout_window(self, db_app, session=self.session), ), ), ( "multi_udc", "UDC Fantasma", "launcher.open_multi_udc", lambda: self._open_child_window( "multi_udc", open_celle_multiple_window(self, db_app, session=self.session), ), ), ( "search", "Ricerca UDC", "launcher.open_search", lambda: self._open_child_window( "search", open_search_window(self, db_app, session=self.session), ), ), ( "pickinglist", "Gestione Picking List", "launcher.open_pickinglist", lambda: self._open_child_window( "pickinglist", open_pickinglist_window(self, db_app, session=self.session), ), ), ( "arrange", "Ridisponi finestre", "launcher.arrange_windows", self._cascade_open_windows, ), ( "exit", "Esci", "launcher.exit", self._shutdown, ), ] used_columns = max(1, min(len(actions), max_buttons_per_row)) info = ctk.CTkLabel( wrap, text=f"Operatore: {self.session.display_name} ({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( "", 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 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.lift() 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 not self._widget_belongs_to_window(window, event_widget): 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_fullsize_below_parent_later(self, window) 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 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") fut = asyncio.run_coroutine_threadsafe(db_app.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( "Warehouse", "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) 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: 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).mainloop()