479 lines
16 KiB
Python
479 lines
16 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, 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(
|
|
"<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
|
|
try:
|
|
window.bind(
|
|
"<ButtonPress-1>",
|
|
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()
|