419 lines
19 KiB
Python
419 lines
19 KiB
Python
# -*- 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)
|