# -*- coding: utf-8 -*- # hub/ticket/ui/ticket_create_page.py """Форма создания заявки в составе независимого приложения Dispatch. Назначение модуля: Страница «Создать заявку», разделённая на две колонки: - Левая колонка занимает 70% ширины и содержит выпадающий список программного продукта, выпадающий список краткого заголовка неисправности и многострочное поле подробного описания неисправности. - Правая колонка занимает 30% ширины и содержит блок действий формы: «Создать заявку», «Отмена», «Прикрепить файл». Источники данных: - `DB_dispatch/3_software_list.py` — справочник программных продуктов (ключ `software_id` → наименование). - `DB_dispatch/4_malfunction_list.py` — справочник кратких заголовков неисправностей (ключ `software_id` → перечень формулировок). - `DB_dispatch/0_users.py` и `DB_dispatch/2_customer_facility_list.py` — учётная запись и место установки заявителя; используются для формирования строки локации заявки. Архитектурные ограничения: - QSS-литералы и фиксированные размеры в прикладном коде не используются: оформление подаётся через ключи `APP_STYLES`, геометрия задаётся процентными долями. - Все элементы построены на канонических обёртках `Button`, `Label`, `ComboBox`, `TextInput`, `SContainer`, `HContainer`, `VContainer`. - Доступ к каталогу `DB_dispatch` осуществляется только через сервис `hub.my_account.auth_service`. """ from __future__ import annotations from datetime import datetime from typing import Callable from gui.components import Button, ComboBox, Label, TextInput from gui.containers import HContainer, SContainer, VContainer from application import TaskApplicationService from domain import TicketTaskSnapshot from domain.location_catalog import parse_location_parts from domain.ticket_constants import ( STATE_TODO, TICKET_STATE_ACTIONS, TICKET_STATE_COLORS, TICKET_STATE_NAMES, ) # Справочные тексты в комбобоксах: используются как placeholder, # чтобы пользователю было понятно назначение поля до открытия списка. _PRODUCT_PLACEHOLDER = "Программный продукт" _SUMMARY_PLACEHOLDER = "Краткий заголовок неисправности" class TicketCreatePage(SContainer): """Двухколоночная форма создания заявки и её отправки на доску `STATE_TODO`.""" def __init__( self, application: TaskApplicationService, on_finish: Callable[[], None], parent=None, ): super().__init__( width_percent=100, height_percent=100, margin=0, parent=parent, style="TICKET_SHELL_ROOT", ) self._application = application self._on_finish = on_finish # Состояние активной сессии заявителя; используется только для # формирования строки локации, в интерфейсе не отображается. self._signed_user: dict | None = None self._user_institution: str = "" self._user_room: str = "" # Справочные таблицы DB_dispatch и индекс «наименование → id» # для определения списка неисправностей по выбранному продукту. self._software_by_id: dict[str, str] = {} self._software_id_by_name: dict[str, str] = {} self._malfunction_by_software_id: dict[str, list[str]] = {} # Поля левой колонки. self._product_combo: ComboBox | None = None self._summary_combo: ComboBox | None = None self._description_input: TextInput | None = None # Действия формы. self._submit_button: Button | None = None self._cancel_button: Button | None = None self._attach_button: Button | None = None self._error_label: Label | None = None self._setup_ui() self._connect_signals() self._load_reference_data() # ── Сборка интерфейса ─────────────────────────────────────────────── def _setup_ui(self) -> None: # Корневой контейнер страницы — горизонтальная компоновка с # явными долями 70% (левая колонка) и 30% (правая колонка). # `HContainer` всегда занимает 100% ширины родителя, поэтому # параметр `width_percent` ему не передаётся. root = HContainer( height_percent=100, margin=[24, 18, 24, 18], spacing=18, content_fit=False, parent=self, ) # ── Левая колонка: поля заявки (70%). ──────────────────────── # `VContainer` всегда занимает 100% высоты родителя, поэтому # `height_percent` опускается; ширина колонки задаётся явно. left = VContainer( width_percent=70, margin=0, spacing=12, content_fit=False, parent=root, ) left.add_widget(Label( "Создать заявку", height_percent=8, style="LOGIN_TITLE", )) # Комбобокс программного продукта: справочный текст-подсказка # отображается в редактируемой строке до выбора пункта. self._product_combo = ComboBox( width_percent=100, height_percent=8, content_fit=False, ) self._product_combo.set_editable(True) self._product_combo.set_placeholder_text(_PRODUCT_PLACEHOLDER) left.add_widget(self._product_combo) # Комбобокс краткого заголовка неисправности: пункты списка # обновляются после выбора программного продукта. self._summary_combo = ComboBox( width_percent=100, height_percent=8, content_fit=False, ) self._summary_combo.set_editable(True) self._summary_combo.set_placeholder_text(_SUMMARY_PLACEHOLDER) left.add_widget(self._summary_combo) self._description_input = TextInput( placeholder=( "Подробно опишите проявления неисправности, шаги " "воспроизведения, коды ошибок и предпринятые действия." ), width_percent=100, height_percent=68, content_fit=False, multiline=True, ) left.add_widget(self._description_input) # ── Правая колонка: блок действий (30%). ────────────────────── right = VContainer( width_percent=30, margin=0, spacing=12, content_fit=False, parent=root, ) self._error_label = Label( "", height_percent=8, style="LOGIN_ERROR_LABEL", ) self._error_label.set_visible(False) right.add_widget(self._error_label) right.add_stretch(1) # Блок действий: «Создать заявку», «Отмена», «Прикрепить файл». # `VContainer` принимает только ширину в процентах от родителя; # высота блока определяется собственными процентами кнопок. actions = VContainer( width_percent=100, margin=0, spacing=8, content_fit=False, parent=right, ) self._submit_button = Button( "Создать заявку", width_percent=100, height_percent=30, margin=0, style="LOGIN_SUBMIT_BUTTON", content_fit=False, ) self._cancel_button = Button( "Отмена", width_percent=100, height_percent=30, margin=0, style="LOGIN_CANCEL_BUTTON", content_fit=False, ) self._attach_button = Button( "Прикрепить файл", width_percent=100, height_percent=30, margin=0, style="LOGIN_NAV_BUTTON", content_fit=False, ) actions.add_widget(self._submit_button) actions.add_widget(self._cancel_button) actions.add_widget(self._attach_button) right.add_widget(actions) # ── Сигналы и справочные данные ───────────────────────────────────── def _connect_signals(self) -> None: if self._submit_button is not None: self._submit_button.clicked.connect(self._on_submit_clicked) if self._cancel_button is not None: self._cancel_button.clicked.connect(self._on_cancel_clicked) if self._attach_button is not None: self._attach_button.clicked.connect(self._on_attach_clicked) if self._product_combo is not None: self._product_combo.current_text_changed.connect( self._on_product_changed, ) def _load_reference_data(self) -> None: """Загрузить справочники программных продуктов и неисправностей. Источники — `DB_dispatch/3_software_list.py` и `DB_dispatch/4_malfunction_list.py`. Полученные таблицы используются для наполнения комбобоксов и для определения перечня неисправностей по выбранному программному продукту. """ # Импорт внутри метода исключает циклическую зависимость # между UI-слоем Ticket и сервисом учётных записей Dispatch. from auth_service import ( load_malfunction_list, load_software_list, ) self._software_by_id = load_software_list() self._malfunction_by_software_id = load_malfunction_list() self._software_id_by_name = { name: software_id for software_id, name in self._software_by_id.items() } self._populate_product_combo() self._populate_summary_combo("") def _populate_product_combo(self) -> None: if self._product_combo is None: return names = sorted(self._software_by_id.values()) self._product_combo.set_items(["", *names]) self._product_combo.set_index(0) def _populate_summary_combo(self, software_name: str) -> None: if self._summary_combo is None: return software_id = self._software_id_by_name.get(software_name, "") if software_id: titles = list(self._malfunction_by_software_id.get(software_id, [])) else: # Пока программный продукт не выбран, показываем полный # перечень заголовков из `DB_dispatch/4_malfunction_list.py`, # сохраняя порядок появления и убирая дубликаты. titles = [] seen: set[str] = set() for values in self._malfunction_by_software_id.values(): for title in values: if title in seen: continue seen.add(title) titles.append(title) self._summary_combo.set_items(["", *titles]) self._summary_combo.set_index(0) def _on_product_changed(self, text: str) -> None: """Обновить набор кратких заголовков под выбранный продукт.""" self._populate_summary_combo((text or "").strip()) def refresh_user_session(self) -> None: """Подтянуть реквизиты заявителя из активной сессии Dispatch. Метод сохраняет данные пользователя только во внутренних полях: учреждение и кабинет нужны для формирования строки локации заявки. В интерфейсе реквизиты заявителя не отображаются. """ from auth_service import get_user_facility, load_session user = load_session() self._signed_user = user if user is None: self._user_institution = "" self._user_room = "" return institution, room, _product = parse_location_parts(get_user_facility(user)) self._user_institution = institution self._user_room = room # ── Обработчики действий ──────────────────────────────────────────── def _on_cancel_clicked(self, _checked: bool = False) -> None: """Сбросить форму и вернуть пользователя на доску заявок.""" self._reset_form() self._on_finish() def _on_attach_clicked(self, _checked: bool = False) -> None: """Заглушка действия прикрепления файла. Полнофункциональная загрузка вложений выходит за рамки текущего этапа; кнопка зарезервирована в разметке и подключает обработчик-уведомление, чтобы пользователь не воспринимал отсутствие реакции как сбой. """ self._show_error("Прикрепление файла будет доступно на следующем этапе.") def _on_submit_clicked(self, _checked: bool = False) -> None: """Проверить поля, собрать snapshot и зарегистрировать заявку.""" if self._signed_user is None: self._show_error("Войдите в систему перед созданием заявки.") return product = self._read_combo_text(self._product_combo) summary = self._read_combo_text(self._summary_combo) description = ( self._description_input.get_text() if self._description_input else "" ).strip() if not product or not summary or not description: self._show_error( "Выберите программный продукт, краткий заголовок и заполните описание.", ) return if not self._user_institution: self._show_error( "Для учётной записи не задано место установки в DB_dispatch.", ) return location = self._compose_location(product, summary) snapshot = self._build_snapshot(location) self._application.submit_new_task(snapshot) self._reset_form() self._hide_error() self._on_finish() @staticmethod def _read_combo_text(combo: ComboBox | None) -> str: if combo is None: return "" return combo.get_current_text().strip() def _compose_location(self, product: str, summary: str) -> str: # Формат локации совпадает с парсером `parse_location_parts`: # «Учреждение (Аппарат — заголовок, каб. №)». Это даёт корректное # отображение карточки на доске без дополнительных адаптеров. device_segment = f"{product} — {summary}" if summary else product if not self._user_room: return f"{self._user_institution} ({device_segment})" return f"{self._user_institution} ({device_segment}, {self._user_room})" def _build_snapshot(self, location: str) -> TicketTaskSnapshot: action_text = TICKET_STATE_ACTIONS.get(STATE_TODO, "") return TicketTaskSnapshot( task_id=self._application.allocate_new_task_id(), location=location, state_code=STATE_TODO, state_name=TICKET_STATE_NAMES.get(STATE_TODO, ""), action_text=action_text, color_hex=TICKET_STATE_COLORS.get(STATE_TODO, "#FFFFFF"), created_at=datetime.now(), ) def _reset_form(self) -> None: """Очистить редактируемые поля левой колонки.""" if self._product_combo is not None: self._product_combo.set_index(0) if self._summary_combo is not None: self._summary_combo.set_index(0) if self._description_input is not None: self._description_input.clear() self._hide_error() # ── Сообщения об ошибках ──────────────────────────────────────────── def _show_error(self, message: str) -> None: if self._error_label is None: return self._error_label.set_text(message) self._error_label.set_visible(True) def _hide_error(self) -> None: if self._error_label is None: return self._error_label.set_text("") self._error_label.set_visible(False)