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