Add Dispatch_V0.1.1
This commit is contained in:
400
Dispatch_V0.1.1/ui/ticket_shell.py
Normal file
400
Dispatch_V0.1.1/ui/ticket_shell.py
Normal file
@@ -0,0 +1,400 @@
|
||||
# -*- 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))
|
||||
Reference in New Issue
Block a user