Add Dispatch_V0.1.1

This commit is contained in:
2026-04-29 08:18:54 +04:00
commit a7ede6ded4
404 changed files with 39167 additions and 0 deletions

View 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))