Files
Dispatch/Dispatch_V0.1.1/ui/ticket_shell.py
2026-04-29 08:18:54 +04:00

401 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# hub/ticket/ui/ticket_shell.py
"""Оболочка модуля Ticket в составе независимого приложения Dispatch.
Назначение модуля:
Минимальная shell-страница Dispatch:
- Единая горизонтальная шапка из трёх равных по ширине кнопок:
кнопка-логотип (открывает контактный диалог), кнопка раздела
«Ваши заявки», кнопка авторизации `Log In` / `Log Out`.
- Центральная область с доской задач Ticket.
- Модуль работы с COM-портом в Dispatch отключён, поэтому строка
состояния COM-канала и подписки на сигналы шлюза удалены.
Архитектурные ограничения:
- Стилевые ключи берутся только из внешнего реестра `APP_STYLES`;
локальные QSS-литералы не используются.
- Все три элемента шапки — экземпляры локальной обёртки `Button`
и распределяют пространство равными долями `stretch=1`.
- Логотип реализован как канонический `Button` с иконкой
(`icon_path`), без кастомного raw-Qt компонента и без inline QSS.
"""
from __future__ import annotations
import os
from functools import partial
from gui.components.button import Button
from gui.components.dialog import Dialog
from gui.components.label import Label
from gui.containers import HContainer, SContainer, StackContainer, VContainer
from gui.theme_bus import theme_bus
from application import TaskApplicationService
from .pages import ArchivePage
from .ticket_board_page import TicketBoardPage
from .ticket_create_page import TicketCreatePage
# Контактные телефоны для модального окна. Текст вынесен в константы,
# чтобы единственное место правки находилось в верхней части файла.
_CONTACT_PHONE_GENERAL = "+7 (000) 000-00-00"
_CONTACT_PHONE_DISPATCHER = "+7 (000) 000-00-00"
_CONTACT_PHONE_SERVICE_HEAD = "+7 (000) 000-00-00"
class _ContactsDialog(Dialog):
"""Простое модальное окно с контактными данными организации."""
def __init__(self, parent=None):
super().__init__(
title="Контактные данные",
width=420,
height=260,
modal=True,
parent=parent,
)
self._setup_ui()
def _setup_ui(self) -> None:
body = VContainer(
margin=[24, 20, 24, 20],
spacing=12,
parent=None,
)
self.add_widget(body)
body.add_widget(Label(
"Контакты службы сервисного обслуживания",
height_percent=20,
style="LOGIN_TITLE",
))
body.add_widget(Label(
f"Общий телефон: {_CONTACT_PHONE_GENERAL}",
height_percent=20,
style="LOGIN_FIELD_LABEL",
))
body.add_widget(Label(
f"Диспетчер: {_CONTACT_PHONE_DISPATCHER}",
height_percent=20,
style="LOGIN_FIELD_LABEL",
))
body.add_widget(Label(
f"Руководитель службы: {_CONTACT_PHONE_SERVICE_HEAD}",
height_percent=20,
style="LOGIN_FIELD_LABEL",
))
actions = SContainer(
height_percent=20,
orientation="h",
content_fit=False,
)
actions.add_stretch(1)
close_button = Button(
text="Закрыть",
height_percent=100,
margin=0,
content_fit=False,
)
close_button.clicked.connect(self.accept)
actions.add_widget(close_button)
body.add_widget(actions)
class TicketShell(SContainer):
"""Корневая оболочка Dispatch: шапка из трёх кнопок и доска задач."""
def __init__(
self,
application: TaskApplicationService,
parent=None,
):
super().__init__(
width_percent=100,
height_percent=100,
margin=0,
spacing=0,
parent=parent,
style="TICKET_SHELL_ROOT",
)
self._application = application
self._page_stack: StackContainer | None = None
self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light"
self._active_view_name = "board"
self._page_index_by_name: dict[str, int] = {}
self._nav_buttons: dict[str, Button] = {}
self._login_button: Button | None = None
self._logo_button: Button | None = None
self._is_logged_in: bool = False
self._logged_in_user: dict | None = None
self._create_page: TicketCreatePage | None = None
self._logo_dark_path, self._logo_light_path = self._resolve_logo_paths()
self._setup_ui()
self._connect_signals()
self._sync_from_application()
self._restore_session()
# ── разметка интерфейса ──
def _setup_ui(self) -> None:
# Единая шапка: один HContainer, три равные по ширине кнопки.
# Высота шапки — 6% высоты shell. Распределение долей
# выполняется через `add_widget_with_stretch(button, 1)` для
# каждой кнопки, что даёт ровно одинаковые сегменты.
top_bar = HContainer(
height_percent=6,
margin=[0, 0, 0, 12],
spacing=16,
content_fit=False,
style="TICKET_SURFACE_HOST",
parent=self,
)
# Кнопка-логотип: при нажатии открывается модальный диалог с
# контактными данными. Иконка логотипа подбирается под тему.
self._logo_button = Button(
text="",
height_percent=100,
margin=0,
is_active=False,
content_fit=False,
icon_path=self._logo_dark_path,
icon_size=40,
)
board_button = Button(
text="Ваши заявки",
height_percent=100,
margin=0,
is_active=True,
content_fit=False,
)
self._nav_buttons["board"] = board_button
create_button = Button(
text="Создать заявку",
height_percent=100,
margin=0,
is_active=False,
content_fit=False,
)
self._nav_buttons["create"] = create_button
archive_button = Button(
text="Архив моих заявок",
height_percent=100,
margin=0,
is_active=False,
content_fit=False,
)
self._nav_buttons["archive"] = archive_button
self._login_button = Button(
text="Log In",
height_percent=100,
margin=0,
is_active=False,
content_fit=False,
style="LOGIN_NAV_BUTTON",
)
top_bar.add_widget_with_stretch(self._logo_button, 1)
top_bar.add_widget_with_stretch(board_button, 1)
top_bar.add_widget_with_stretch(create_button, 1)
top_bar.add_widget_with_stretch(archive_button, 1)
top_bar.add_widget_with_stretch(self._login_button, 1)
# Центральный stack-контейнер: в Dispatch удерживает три страницы — доску, создание и архив.
self._page_stack = StackContainer(margin=0, parent=self)
board_page = TicketBoardPage(application=self._application)
create_page = TicketCreatePage(
application=self._application,
on_finish=self._on_create_form_finished,
)
self._create_page = create_page
archive_page = ArchivePage(application=self._application)
self._page_index_by_name["board"] = self._page_stack.add_widget(board_page)
self._page_index_by_name["create"] = self._page_stack.add_widget(create_page)
self._page_index_by_name["archive"] = self._page_stack.add_widget(archive_page)
self._page_stack.set_current_index(self._page_index_by_name["board"])
def _connect_signals(self) -> None:
self._nav_buttons["board"].clicked.connect(partial(self._on_navigation_requested, "board"))
self._nav_buttons["create"].clicked.connect(partial(self._on_navigation_requested, "create"))
self._nav_buttons["archive"].clicked.connect(partial(self._on_navigation_requested, "archive"))
if self._login_button is not None:
self._login_button.clicked.connect(self._on_login_button_clicked)
if self._logo_button is not None:
self._logo_button.clicked.connect(self._on_logo_button_clicked)
self._application.active_view_changed.connect(self._on_active_view_changed)
theme_bus.theme_changed.connect(self._on_theme_changed)
def _sync_from_application(self) -> None:
self._set_active_page(self._application.get_active_view())
# ── навигация и страницы ──
def _set_active_page(self, view_name: str) -> None:
if self._page_stack is None:
return
normalized_name = view_name if view_name in self._page_index_by_name else "board"
self._active_view_name = normalized_name
target_index = self._page_index_by_name.get(normalized_name)
if target_index is None:
return
if self._page_stack.current_index() != target_index:
self._page_stack.set_current_index(target_index)
self._apply_navigation_theme()
def _on_active_view_changed(self, view_name: str) -> None:
self._set_active_page(view_name)
def _on_navigation_requested(self, view_name: str, _checked: bool = False) -> None:
if view_name == "create" and self._create_page is not None:
# При каждом входе на страницу форма подтягивает актуальные данные заявителя.
self._create_page.refresh_user_session()
self._application.set_active_view(view_name)
def _on_create_form_finished(self) -> None:
"""После сохранения или отмены формы вернуть пользователя на доску заявок."""
self._application.set_active_view("board")
# ── авторизация ──
def _on_login_button_clicked(self, _checked: bool = False) -> None:
"""Переключить состояние сессии: вход через диалог или выход.
Источник учётных данных — каталог `DB_dispatch`. Сценарий
полностью повторяет схему USMS: при отсутствии активного
пользователя открывается модальный диалог авторизации,
иначе выполняется выход и очистка файла активной сессии.
"""
if self._login_button is None:
return
if self._logged_in_user is not None:
self._do_logout()
else:
self._do_login()
def _do_login(self) -> None:
"""Открыть диалог авторизации и при успехе записать сессию."""
# Импорты вынесены в момент вызова, чтобы избежать циклической
# загрузки модулей при старте приложения Dispatch.
from gui.login_dialog import LoginDialog
from auth_service import write_session
dialog = LoginDialog(parent=self)
if dialog.exec() != LoginDialog.DialogCode.Accepted:
return
user = dialog.get_authenticated_user()
if user is None:
return
write_session(user)
self._apply_logged_in_state(user)
def _do_logout(self) -> None:
"""Очистить запись активной сессии и вернуть кнопку в Log In."""
from auth_service import clear_session
clear_session()
self._apply_logged_out_state()
def _restore_session(self) -> None:
"""Восстановить состояние входа из `DB_dispatch/1_actual_state.py`."""
from auth_service import load_session
user = load_session()
if user is not None:
self._apply_logged_in_state(user)
def _apply_logged_in_state(self, user: dict) -> None:
"""Перевести кнопку входа в состояние Log Out для активного пользователя."""
self._logged_in_user = user
self._is_logged_in = True
if self._login_button is None:
return
self._login_button.set_text("Log Out")
self._login_button.style(is_active=True)
def _apply_logged_out_state(self) -> None:
"""Перевести кнопку входа обратно в состояние Log In."""
self._logged_in_user = None
self._is_logged_in = False
if self._login_button is None:
return
self._login_button.set_text("Log In")
self._login_button.style(is_active=False)
# ── контактный диалог ──
def _on_logo_button_clicked(self, _checked: bool = False) -> None:
"""Открыть модальное окно с контактными телефонами организации."""
dialog = _ContactsDialog(parent=self)
dialog.exec()
# ── оформление и тема ──
def _on_theme_changed(self, theme: str) -> None:
self._apply_theme(theme)
def _apply_theme(self, theme: str) -> None:
normalized_theme = (theme or "").strip().lower()
if normalized_theme not in {"dark", "light"}:
return
self._theme = normalized_theme
self._apply_navigation_theme()
self._update_logo_icon(normalized_theme)
def _apply_navigation_theme(self) -> None:
normal_key, active_key = self._navigation_style_keys()
for page_name, button in self._nav_buttons.items():
button.style(
style_key=normal_key,
active_key=active_key,
is_active=page_name == self._active_view_name,
)
def _navigation_style_keys(self) -> tuple[str, str]:
if self._theme == "light":
return ("TAB_BUTTON_NORMAL_LIGHT", "TAB_BUTTON_ACTIVE_LIGHT")
return ("TAB_BUTTON_NORMAL", "TAB_BUTTON_ACTIVE")
def _resolve_logo_paths(self) -> tuple[str, str]:
"""Вычислить пути файлов логотипа из локального каталога `gui/components/logo`."""
# __file__ = .../dispatch/hub/ticket/ui/ticket_shell.py
# project_root = .../dispatch
project_root = os.path.dirname(
os.path.dirname(
os.path.dirname(
os.path.dirname(os.path.abspath(__file__))
)
)
)
logo_dir = os.path.join(project_root, "gui", "components", "logo")
return (
os.path.join(logo_dir, "Nutshell_Logo_ENG_White.png"),
os.path.join(logo_dir, "Nutshell_Logo_ENG_Black.png"),
)
def _update_logo_icon(self, theme: str) -> None:
"""Подобрать иконку кнопки-логотипа под текущую тему оформления."""
if self._logo_button is None:
return
is_light = str(theme or "").strip().lower() == "light"
path = self._logo_light_path if is_light else self._logo_dark_path
from PySide6.QtCore import QSize
from PySide6.QtGui import QIcon
# Используем штатное API QPushButton, доступ к которому Button предоставляет
# как делегат через свойство `clicked`. Иконку обновляем в обход публичного API,
# потому что Button не имеет канонического `set_icon` метода в текущем wrapper-слое.
inner_button = getattr(self._logo_button, "_button", None)
if inner_button is not None:
inner_button.setIcon(QIcon(path))
inner_button.setIconSize(QSize(40, 40))