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