Files
ware_house/main.py

609 lines
22 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
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(
"<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 _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))