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

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# hub/ticket/application/__init__.py
"""Публичный application-контур Ticket."""
from .archive_service import ArchiveService
from .document_flow_service import DocumentFlowService
from .report_signing_service import ReportSigningService
from .specialist_assignment_service import SpecialistAssignmentService
from .task_application_service import TaskApplicationService
from .ticket_application_api import TicketApplicationApi
__all__ = [
"ArchiveService",
"DocumentFlowService",
"ReportSigningService",
"SpecialistAssignmentService",
"TaskApplicationService",
"TicketApplicationApi",
]

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# hub/ticket/application/archive_service.py
"""Application-сервис архивации задач Ticket."""
from __future__ import annotations
from error_logger import log_exception
from domain import ArchiveRecordSnapshot, TicketStateService
from domain.task import TicketTask
from state import ArchiveRecordRepository, TicketStateApi
from .document_flow_service import DocumentFlowService
class ArchiveService:
"""Команда архивации поверх доменного сервиса и state API."""
def __init__(
self,
state: TicketStateApi,
state_service: TicketStateService,
archive_repository: ArchiveRecordRepository | None = None,
document_service: DocumentFlowService | None = None,
):
self._state = state
self._state_service = state_service
self._archive_repository = archive_repository or ArchiveRecordRepository()
self._document_service = document_service
def archive_task(self, task_id: int):
"""Перевести задачу в архив и сохранить архивную запись."""
snapshot = self._state.get_task(task_id)
if snapshot is None:
return None
self.ensure_archive_record(snapshot)
result = self._state_service.move_task_to_archive(
task_id,
TicketTask.from_snapshot(snapshot),
)
if result.task is None:
return None
archived_snapshot = result.task.to_snapshot()
self._state.upsert_task(archived_snapshot)
return archived_snapshot
def list_archive_records(self) -> list[ArchiveRecordSnapshot]:
"""Вернуть все архивные записи из файлового хранилища."""
return self._archive_repository.list_records()
def ensure_archive_record(self, snapshot) -> None:
"""Сохранить архивную запись, если её ещё нет для данного цикла задачи."""
try:
cycle_token = self._build_cycle_token(snapshot)
if self._archive_repository.has_record(snapshot.task_id, cycle_token):
return
documents = self._collect_cycle_documents(snapshot)
self._archive_repository.save_record(snapshot, documents)
except Exception as exc:
log_exception(__name__, "ArchiveService.ensure_archive_record", exc)
def _collect_cycle_documents(self, snapshot) -> list:
"""Вернуть документы только текущего цикла задачи."""
if self._document_service is None:
return []
cycle_token = self._build_cycle_token(snapshot)
all_docs = self._document_service.list_documents()
return [
d for d in all_docs
if d.task_id == snapshot.task_id and cycle_token in d.document_id
]
@staticmethod
def _build_cycle_token(snapshot) -> str:
from datetime import datetime as _dt
if snapshot.created_at is not None:
return snapshot.created_at.strftime("%Y%m%d_%H%M%S")
return _dt.now().strftime("%Y%m%d_%H%M%S")

View File

@@ -0,0 +1,230 @@
# -*- coding: utf-8 -*-
# hub/ticket/application/document_flow_service.py
"""Application-сервис генерации документов Ticket."""
from __future__ import annotations
from typing import Callable
from domain import TicketDocumentSnapshot, TicketTaskSnapshot, parse_location_parts
from domain.task import TicketTask
from domain.ticket_constants import STATE_CONFIRMATION, STATE_IN_PROGRESS
from state import TicketDocumentRepository, TicketStateApi
class DocumentFlowService:
"""Канонический document-flow Ticket поверх state и файлового репозитория."""
def __init__(
self,
state: TicketStateApi,
repository: TicketDocumentRepository | None = None,
):
self._state = state
self._repository = repository or TicketDocumentRepository()
def create_diagnostic_report(
self,
task_id: int,
initial_cause: str,
actual_cause: str,
) -> TicketDocumentSnapshot:
task = self._get_task_or_raise(task_id)
self._ensure_in_progress(task, "Диагностический отчёт")
self._ensure_specialist_assigned(task)
if task.diagnostic_report_signed:
raise ValueError("Диагностический отчёт уже подписан.")
if not initial_cause.strip() or not actual_cause.strip():
raise ValueError("Заполните первичное и вторичное заключения.")
payload = self._base_payload(task)
payload.update(
{
"initial_cause": initial_cause.strip(),
"actual_cause": actual_cause.strip(),
}
)
document = self._save_document(
task=task,
document_type="diagnostic",
title=f"Диагностический отчёт по задаче #{task.sequence_number or task.task_id}",
summary=actual_cause.strip(),
payload=payload,
content_builder=self._render_diagnostic_report,
)
self._state.sign_report(task.task_id, "diagnostic")
return document
def create_repair_report(
self,
task_id: int,
work_done: str,
used_parts: str,
recommendations: str,
) -> TicketDocumentSnapshot:
task = self._get_task_or_raise(task_id)
self._ensure_in_progress(task, "Ремонтный отчёт")
self._ensure_specialist_assigned(task)
if task.repair_report_signed:
raise ValueError("Ремонтный отчёт уже подписан.")
if not work_done.strip():
raise ValueError("Заполните поле 'Выполненные работы'.")
payload = self._base_payload(task)
payload.update(
{
"work_done": work_done.strip(),
"used_parts": used_parts.strip(),
"recommendations": recommendations.strip(),
}
)
document = self._save_document(
task=task,
document_type="repair",
title=f"Ремонтный отчёт по задаче #{task.sequence_number or task.task_id}",
summary=work_done.strip(),
payload=payload,
content_builder=self._render_repair_report,
)
self._state.sign_report(task.task_id, "repair")
return document
def create_acceptance_report(
self,
task_id: int,
work_description: str,
executor_signature: str,
customer_signature: str,
) -> TicketDocumentSnapshot:
task = self._get_task_or_raise(task_id)
if task.state_code != STATE_CONFIRMATION:
raise ValueError("Акт приёмки доступен только в состоянии подтверждения.")
if task.acceptance_report_signed:
raise ValueError("Акт приёмки уже подписан.")
if not task.diagnostic_report_signed or not task.repair_report_signed:
raise ValueError("Сначала подпишите диагностический и ремонтный отчёты.")
if not work_description.strip():
raise ValueError("Заполните описание выполненных работ.")
if not executor_signature.strip() or not customer_signature.strip():
raise ValueError("Укажите подписи исполнителя и заказчика.")
payload = self._base_payload(task)
payload.update(
{
"work_description": work_description.strip(),
"executor_signature": executor_signature.strip(),
"customer_signature": customer_signature.strip(),
}
)
document = self._save_document(
task=task,
document_type="acceptance",
title=f"Акт приёмки по задаче #{task.sequence_number or task.task_id}",
summary=work_description.strip(),
payload=payload,
content_builder=self._render_acceptance_report,
)
task_data = TicketTask.from_snapshot(task).to_record()
task_data["acceptance_report_signed"] = True
self._state.update_task(task_data)
return document
def list_documents(
self,
document_type: str | None = None,
) -> list[TicketDocumentSnapshot]:
"""Вернуть отсортированный список документов Ticket."""
return self._repository.list_documents(document_type)
def _save_document(
self,
task: TicketTaskSnapshot,
document_type: str,
title: str,
summary: str,
payload: dict[str, str],
content_builder: Callable[[dict[str, str]], str],
) -> TicketDocumentSnapshot:
document = self._repository.save_document(
task=task,
document_type=document_type,
title=title,
summary=summary,
content=content_builder(payload),
payload=payload,
)
if document is None:
raise ValueError("Не удалось сохранить документ Ticket.")
return document
def _get_task_or_raise(self, task_id: int) -> TicketTaskSnapshot:
task = self._state.get_task(task_id)
if task is None:
raise ValueError(f"Задача #{task_id} не найдена.")
return task
@staticmethod
def _ensure_in_progress(task: TicketTaskSnapshot, document_name: str) -> None:
if task.state_code != STATE_IN_PROGRESS:
raise ValueError(f"{document_name} можно подписать только в состоянии 'В работе'.")
@staticmethod
def _ensure_specialist_assigned(task: TicketTaskSnapshot) -> None:
if not task.assigned_specialist.strip():
raise ValueError("Сначала назначьте специалиста.")
@staticmethod
def _base_payload(task: TicketTaskSnapshot) -> dict[str, str]:
institution, room, device = parse_location_parts(task.location)
return {
"task_id": str(task.sequence_number or task.task_id),
"institution": institution,
"room": room,
"device": device,
"location": task.location,
"specialist": task.assigned_specialist,
}
@staticmethod
def _render_diagnostic_report(payload: dict[str, str]) -> str:
return (
f"ДИАГНОСТИЧЕСКИЙ ОТЧЁТ #{payload['task_id']}\n\n"
f"Учреждение: {payload.get('institution', '')}\n"
f"Оборудование: {payload.get('device', '')}\n"
f"Кабинет: {payload.get('room', '')}\n"
f"Специалист: {payload.get('specialist', '')}\n\n"
"Первичное заключение:\n"
f"{payload.get('initial_cause', '')}\n\n"
"Вторичное заключение:\n"
f"{payload.get('actual_cause', '')}\n"
)
@staticmethod
def _render_repair_report(payload: dict[str, str]) -> str:
return (
f"РЕМОНТНЫЙ ОТЧЁТ #{payload['task_id']}\n\n"
f"Учреждение: {payload.get('institution', '')}\n"
f"Оборудование: {payload.get('device', '')}\n"
f"Кабинет: {payload.get('room', '')}\n"
f"Специалист: {payload.get('specialist', '')}\n\n"
"Выполненные работы:\n"
f"{payload.get('work_done', '')}\n\n"
"Использованные запчасти:\n"
f"{payload.get('used_parts', '')}\n\n"
"Рекомендации:\n"
f"{payload.get('recommendations', '')}\n"
)
@staticmethod
def _render_acceptance_report(payload: dict[str, str]) -> str:
return (
f"АКТ ПРИЁМКИ #{payload['task_id']}\n\n"
f"Учреждение: {payload.get('institution', '')}\n"
f"Оборудование: {payload.get('device', '')}\n"
f"Кабинет: {payload.get('room', '')}\n"
f"Специалист: {payload.get('specialist', '')}\n\n"
"Описание выполненных работ:\n"
f"{payload.get('work_description', '')}\n\n"
"Исполнитель:\n"
f"{payload.get('executor_signature', '')}\n\n"
"Заказчик:\n"
f"{payload.get('customer_signature', '')}\n"
)

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# hub/ticket/application/report_signing_service.py
"""Application-сервис подписания отчётов Ticket."""
from __future__ import annotations
from domain.task import TicketTask
from domain.ticket_constants import STATE_CONFIRMATION
from state import TicketStateApi
class ReportSigningService:
"""Команды подписания отчётов поверх канонического state API."""
def __init__(self, state: TicketStateApi):
self._state = state
def sign_report(self, task_id: int, report_type: str):
"""Подписать диагностический или ремонтный отчёт."""
snapshot = self._state.get_task(task_id)
if snapshot is None:
return None
if report_type not in {"diagnostic", "repair"}:
return None
self._state.sign_report(task_id, report_type)
return self._state.get_task(task_id)
def sign_acceptance_report(self, task_id: int):
"""Подписать акт приёмки без смены доменного состояния."""
snapshot = self._state.get_task(task_id)
if snapshot is None:
return None
task_data = TicketTask.from_snapshot(snapshot).to_record()
task_data["acceptance_report_signed"] = True
return self._state.update_task(task_data)
def can_advance_to_confirmation(self, task_id: int) -> bool:
"""Проверить готовность задачи к переходу в подтверждение."""
return self._state.can_advance_to_confirmation(task_id)
def can_advance_to_completed(self, task_id: int) -> bool:
"""Проверить готовность задачи к переходу в выполненные."""
snapshot = self._state.get_task(task_id)
if snapshot is None:
return False
return (
snapshot.state_code == STATE_CONFIRMATION
and snapshot.acceptance_report_signed
)

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# hub/ticket/application/specialist_assignment_service.py
"""Application-сервис назначения специалистов в Ticket."""
from __future__ import annotations
from domain.task import TicketTask
from state import TicketStateApi
SPECIALIST_PHOTOS = {
"Иванов Алексей Сергеевич": "specialist1.png",
"Петрова Мария Владимировна": "specialist2.png",
"Сидоров Дмитрий Иванович": "specialist3.png",
"Козлова Анна Петровна": "specialist4.png",
"Васильев Сергей Николаевич": "specialist5.png",
"Николаева Ольга Дмитриевна": "specialist6.png",
"Фёдоров Андрей Викторович": "specialist7.png",
"Орлова Екатерина Александровна": "specialist8.png",
}
class SpecialistAssignmentService:
"""Команда назначения специалиста поверх канонического state API."""
def __init__(self, state: TicketStateApi):
self._state = state
def assign_specialist(self, task_id: int, specialist_name: str):
"""Назначить специалиста и сохранить фотографию профиля."""
snapshot = self._state.get_task(task_id)
if snapshot is None:
return None
task_data = TicketTask.from_snapshot(snapshot).to_record()
task_data["assigned_specialist"] = specialist_name
task_data["specialist_photo"] = SPECIALIST_PHOTOS.get(specialist_name, "")
return self._state.update_task(task_data)
def list_specialists(self) -> list[str]:
"""Вернуть канонический список доступных специалистов."""
return list(SPECIALIST_PHOTOS.keys())

View File

@@ -0,0 +1,321 @@
# -*- coding: utf-8 -*-
# hub/ticket/application/task_application_service.py
"""Единый application-фасад Ticket поверх state, domain и hardware gateway."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from PySide6.QtCore import QObject, Signal
from error_logger import log_exception
from domain import (
ArchiveRecordSnapshot,
TicketConnectionStatus,
TicketDocumentSnapshot,
TicketHardwareStatus,
TicketStateService,
TicketTaskSnapshot,
)
from domain.task import TicketTask
from domain.ticket_constants import STATE_COMPLETED, STATE_REFUSED
from services import ServiceManager, TicketHardwareGateway
from state import TicketRuntimeState
from .archive_service import ArchiveService
from .document_flow_service import DocumentFlowService
from .report_signing_service import ReportSigningService
from .specialist_assignment_service import SpecialistAssignmentService
class TaskApplicationService(QObject):
"""Канонический application facade Ticket."""
task_updated = Signal(object)
task_removed = Signal(int)
connection_changed = Signal(str, str)
active_view_changed = Signal(str)
state_loaded = Signal()
com_connection_changed = Signal(bool, str)
button_initialization_changed = Signal(bool, int)
def __init__(
self,
state: TicketRuntimeState | None = None,
hardware_gateway: TicketHardwareGateway | None = None,
state_service: TicketStateService | None = None,
specialist_service: SpecialistAssignmentService | None = None,
report_service: ReportSigningService | None = None,
archive_service: ArchiveService | None = None,
document_service: DocumentFlowService | None = None,
parent: QObject | None = None,
):
super().__init__(parent)
self._state = state or TicketRuntimeState()
self._hardware_gateway = hardware_gateway or ServiceManager(parent=self)
self._state_service = state_service or TicketStateService()
self._specialist_service = specialist_service or SpecialistAssignmentService(self._state)
self._report_service = report_service or ReportSigningService(self._state)
self._document_service = document_service or DocumentFlowService(self._state)
self._archive_service = archive_service or ArchiveService(
self._state,
self._state_service,
document_service=self._document_service,
)
self._started = False
self._connect_state_signals()
def start(self) -> None:
"""Загрузить state, синхронизировать gateway и запустить сервисы."""
if self._started:
return
self._started = True
self._state.load()
self._hardware_gateway.reset_button_states()
for task in self._state.list_tasks():
self._hardware_gateway.set_button_state(task.task_id, task.state_code)
self._hardware_gateway.set_observer(self)
self._hardware_gateway.start()
def stop(self) -> None:
"""Остановить gateway и снять observer."""
if not self._started:
return
self._started = False
self._hardware_gateway.set_observer(None)
self._hardware_gateway.stop()
def list_tasks(self) -> list[TicketTaskSnapshot]:
return list(self._state.list_tasks())
def list_active_tasks(self) -> list[TicketTaskSnapshot]:
return list(self._state.list_active_tasks())
def list_archived_tasks(self) -> list[TicketTaskSnapshot]:
return list(self._state.list_archived_tasks())
def list_archive_records(self) -> list[ArchiveRecordSnapshot]:
return self._archive_service.list_archive_records()
def get_task(self, task_id: int) -> TicketTaskSnapshot | None:
return self._state.get_task(task_id)
def handle_task_action(self, raw_action: Mapping[str, Any]) -> TicketTaskSnapshot | None:
"""Обработать аппаратное действие через доменную state-machine."""
button_id = self._normalize_int(raw_action.get("button_id"))
hardware_state = self._normalize_int(raw_action.get("hardware_state"))
if button_id is None or hardware_state is None:
return None
current_snapshot = self._state.get_task(button_id)
current_task = (
TicketTask.from_snapshot(current_snapshot)
if current_snapshot is not None
else None
)
result = self._state_service.handle_hardware_signal(
button_id,
hardware_state,
current_task,
)
if result.task is not None:
self._assign_sequence_on_terminal_state(result.task)
updated_snapshot = result.task.to_snapshot()
self._state.upsert_task(updated_snapshot)
self._hardware_gateway.set_button_state(
updated_snapshot.task_id,
updated_snapshot.state_code,
)
self._auto_save_archive_record(updated_snapshot)
return updated_snapshot
if current_snapshot is not None:
self._hardware_gateway.set_button_state(
current_snapshot.task_id,
current_snapshot.state_code,
)
return current_snapshot
def set_active_view(self, view_name: str) -> None:
self._state.active_view = view_name
def submit_new_task(self, snapshot: TicketTaskSnapshot) -> TicketTaskSnapshot | None:
"""Зарегистрировать заявку, созданную через форму Dispatch.
Назначение: добавляет в runtime-state готовый снимок задачи в
состоянии «Новая заявка» (`STATE_TODO`). Вызывает `upsert_task`,
который эмитит `task_updated` — доска подхватывает карточку без
дополнительной шины событий. Аппаратный шлюз в Dispatch
отключён (`NullHardwareGateway`), поэтому синхронизация
состояния кнопок не выполняется.
"""
if snapshot is None:
return None
self._state.upsert_task(snapshot)
return self._state.get_task(snapshot.task_id)
def allocate_new_task_id(self) -> int:
"""Выдать свободный идентификатор для заявки, созданной диспетчером."""
existing_ids = [task.task_id for task in self._state.list_tasks()]
base = max(existing_ids, default=0)
# Резервируем диапазон 1..99 за аппаратными button_id, чтобы при
# будущей реальной интеграции с COM-каналом идентификаторы заявок
# из формы не пересекались с физическими кнопками.
return max(base, 99) + 1
def assign_specialist(
self,
task_id: int,
specialist_name: str,
) -> TicketTaskSnapshot | None:
snapshot = self._specialist_service.assign_specialist(task_id, specialist_name)
return self._sync_gateway_state(snapshot)
def list_specialists(self) -> list[str]:
return self._specialist_service.list_specialists()
def sign_report(
self,
task_id: int,
report_type: str,
) -> TicketTaskSnapshot | None:
snapshot = self._report_service.sign_report(task_id, report_type)
return self._sync_gateway_state(snapshot)
def sign_acceptance_report(self, task_id: int) -> TicketTaskSnapshot | None:
snapshot = self._report_service.sign_acceptance_report(task_id)
return self._sync_gateway_state(snapshot)
def archive_task(self, task_id: int) -> TicketTaskSnapshot | None:
snapshot = self._archive_service.archive_task(task_id)
return self._sync_gateway_state(snapshot)
def refuse_task(
self,
task_id: int,
refusal_reason: str,
) -> TicketTaskSnapshot | None:
normalized_reason = str(refusal_reason or "").strip()
if not normalized_reason:
return None
current_snapshot = self._state.get_task(task_id)
current_task = (
TicketTask.from_snapshot(current_snapshot)
if current_snapshot is not None
else None
)
result = self._state_service.mark_task_as_refused(
task_id,
current_task,
normalized_reason,
)
if result.task is None:
return None
self._assign_sequence_on_terminal_state(result.task)
snapshot = result.task.to_snapshot()
self._state.upsert_task(snapshot)
self._auto_save_archive_record(snapshot)
return self._sync_gateway_state(snapshot)
def create_diagnostic_report(
self, task_id: int, initial_cause: str, actual_cause: str,
) -> TicketDocumentSnapshot:
return self._document_service.create_diagnostic_report(
task_id, initial_cause, actual_cause,
)
def create_repair_report(
self, task_id: int, work_done: str, used_parts: str, recommendations: str,
) -> TicketDocumentSnapshot:
return self._document_service.create_repair_report(
task_id, work_done, used_parts, recommendations,
)
def create_acceptance_report(
self, task_id: int, work_description: str,
executor_signature: str, customer_signature: str,
) -> TicketDocumentSnapshot:
return self._document_service.create_acceptance_report(
task_id, work_description, executor_signature, customer_signature,
)
def list_documents(
self,
document_type: str | None = None,
) -> list[TicketDocumentSnapshot]:
return self._document_service.list_documents(document_type)
def can_advance_to_confirmation(self, task_id: int) -> bool:
return self._report_service.can_advance_to_confirmation(task_id)
def can_advance_to_completed(self, task_id: int) -> bool:
return self._report_service.can_advance_to_completed(task_id)
def get_active_view(self) -> str:
return self._state.active_view
def get_gateway_status(self) -> TicketHardwareStatus:
return self._hardware_gateway.get_status()
def on_task_action(self, raw_action: Mapping[str, Any]) -> None:
"""Observer callback от hardware gateway."""
try:
self.handle_task_action(raw_action)
except Exception as exc:
log_exception(__name__, "TaskApplicationService.on_task_action", exc)
def on_gateway_status(self, status: TicketHardwareStatus) -> None:
"""Observer callback обновления статуса hardware gateway."""
if status.connection_status == TicketConnectionStatus.ERROR:
self._state.set_error(status.message)
else:
self._state.connection_status = status.connection_status
self._state.set_com_connection(
status.connection_status == TicketConnectionStatus.CONNECTED,
status.message,
)
self._state.set_button_initialization(
status.buttons_initialized,
status.button_count,
)
def on_gateway_error(self, message: str) -> None:
"""Observer callback ошибки hardware gateway."""
self._state.set_error(message)
def _connect_state_signals(self) -> None:
self._state.task_updated.connect(self.task_updated.emit)
self._state.task_removed.connect(self.task_removed.emit)
self._state.connection_changed.connect(self.connection_changed.emit)
self._state.active_view_changed.connect(self.active_view_changed.emit)
self._state.state_loaded.connect(self.state_loaded.emit)
self._state.com_connection_changed.connect(self.com_connection_changed.emit)
self._state.button_initialization_changed.connect(
self.button_initialization_changed.emit
)
def _sync_gateway_state(
self,
snapshot: TicketTaskSnapshot | None,
) -> TicketTaskSnapshot | None:
if snapshot is None:
return None
self._hardware_gateway.set_button_state(snapshot.task_id, snapshot.state_code)
return snapshot
def _assign_sequence_on_terminal_state(self, task: TicketTask) -> None:
"""Присвоить сквозной номер при переходе в Выполненные/Отказ."""
if task.state_code in {STATE_COMPLETED, STATE_REFUSED}:
task.sequence_number = self._state.next_sequence_number()
def _auto_save_archive_record(self, snapshot: TicketTaskSnapshot) -> None:
if snapshot.state_code in {STATE_COMPLETED, STATE_REFUSED}:
self._archive_service.ensure_archive_record(snapshot)
@staticmethod
def _normalize_int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None

View File

@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
# hub/ticket/application/ticket_application_api.py
"""Публичный application API модуля Ticket."""
from __future__ import annotations
from typing import Any, Mapping, Protocol, Sequence
from domain import TicketDocumentSnapshot, TicketTaskSnapshot
class TicketApplicationApi(Protocol):
"""Контракт orchestration-слоя между UI, state и сервисами."""
def start(self) -> None:
"""Инициализировать application-слой Ticket."""
def stop(self) -> None:
"""Остановить активные операции и освободить ресурсы."""
def list_tasks(self) -> Sequence[TicketTaskSnapshot]:
"""Вернуть текущий срез задач для UI."""
def list_active_tasks(self) -> Sequence[TicketTaskSnapshot]:
"""Вернуть активные задачи для UI."""
def list_archived_tasks(self) -> Sequence[TicketTaskSnapshot]:
"""Вернуть архивные задачи для UI."""
def get_task(self, task_id: int) -> TicketTaskSnapshot | None:
"""Вернуть задачу по идентификатору."""
def handle_task_action(
self,
raw_action: Mapping[str, Any],
) -> TicketTaskSnapshot | None:
"""Обработать входящее действие от аппаратного или mock-шлюза."""
def set_active_view(self, view_name: str) -> None:
"""Переключить активное представление Ticket."""
def assign_specialist(
self,
task_id: int,
specialist_name: str,
) -> TicketTaskSnapshot | None:
"""Назначить специалиста на задачу."""
def list_specialists(self) -> Sequence[str]:
"""Вернуть список доступных специалистов."""
def sign_report(
self,
task_id: int,
report_type: str,
) -> TicketTaskSnapshot | None:
"""Подписать диагностический или ремонтный отчёт."""
def sign_acceptance_report(self, task_id: int) -> TicketTaskSnapshot | None:
"""Подписать акт приёмки."""
def archive_task(self, task_id: int) -> TicketTaskSnapshot | None:
"""Перевести задачу в архив."""
def refuse_task(
self,
task_id: int,
refusal_reason: str,
) -> TicketTaskSnapshot | None:
"""Перевести задачу в отказ."""
def create_diagnostic_report(
self,
task_id: int,
initial_cause: str,
actual_cause: str,
) -> TicketDocumentSnapshot:
"""Создать и подписать диагностический отчёт."""
def create_repair_report(
self,
task_id: int,
work_done: str,
used_parts: str,
recommendations: str,
) -> TicketDocumentSnapshot:
"""Создать и подписать ремонтный отчёт."""
def create_acceptance_report(
self,
task_id: int,
work_description: str,
executor_signature: str,
customer_signature: str,
) -> TicketDocumentSnapshot:
"""Создать и подписать акт приёмки."""
def list_documents(
self,
document_type: str | None = None,
) -> Sequence[TicketDocumentSnapshot]:
"""Вернуть список документов Ticket."""
def can_advance_to_confirmation(self, task_id: int) -> bool:
"""Проверить готовность задачи к переходу в подтверждение."""
def can_advance_to_completed(self, task_id: int) -> bool:
"""Проверить готовность задачи к переходу в выполненные."""
def get_active_view(self) -> str:
"""Вернуть имя активного внутреннего представления Ticket."""
def get_gateway_status(self):
"""Вернуть последний известный статус hardware gateway."""

View File

@@ -0,0 +1,180 @@
# -*- coding: utf-8 -*-
# hub/my_account/auth_service.py
"""Сервис аутентификации Dispatch: проверка логина/пароля и запись сессии.
Назначение модуля:
Полностью повторяет контракт сервиса USMS `hub/my_account/auth_service`,
но обращается к локальному каталогу `DB_dispatch` независимого
приложения Dispatch. Источники данных:
- `DB_dispatch/0_users.py` — список учётных записей диспетчеров
и руководителей сервисной службы;
- `DB_dispatch/1_actual_state.py` — текущая активная сессия.
Архитектурные ограничения:
- Каталог `DB_dispatch` располагается на одном уровне с каталогом
`dispatch`, поэтому путь вычисляется относительно текущего файла.
- Файлы данных читаются через `importlib.util` без подключения
к фреймворку USMS. Это сохраняет независимость дистрибутива.
- Запись сессии перезаписывает файл целиком, без частичных правок.
"""
from __future__ import annotations
import importlib.util
import os
import uuid
from datetime import datetime, timezone
def _resolve_db_dir() -> str:
"""Вернуть абсолютный путь к каталогу `DB_dispatch`.
Структура размещения:
<project_root>/dispatch/hub/my_account/auth_service.py
<project_root>/DB_dispatch/0_users.py
Поэтому переход — четыре уровня вверх от текущего файла, затем
спуск в каталог `DB_dispatch`.
"""
here = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(os.path.dirname(here)))
return os.path.join(project_root, "DB_dispatch")
_DB_DIR = _resolve_db_dir()
def _load_users() -> list[dict]:
"""Загрузить список пользователей из `DB_dispatch/0_users.py`."""
path = os.path.join(_DB_DIR, "0_users.py")
spec = importlib.util.spec_from_file_location("_dispatch_db_users", path)
if spec is None or spec.loader is None:
return []
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return list(getattr(mod, "users", []))
def load_facility_locations() -> dict[str, str]:
"""Загрузить таблицу `facility_id → строка локации` из `DB_dispatch/2_customer_facility_list.py`."""
path = os.path.join(_DB_DIR, "2_customer_facility_list.py")
if not os.path.exists(path):
return {}
spec = importlib.util.spec_from_file_location("_dispatch_db_facilities", path)
if spec is None or spec.loader is None:
return {}
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
raw = getattr(mod, "DEFAULT_BUTTON_LOCATIONS", {})
return {str(k): str(v) for k, v in dict(raw).items()}
def load_software_list() -> dict[str, str]:
"""Загрузить таблицу `software_id → наименование` из `DB_dispatch/3_software_list.py`."""
path = os.path.join(_DB_DIR, "3_software_list.py")
if not os.path.exists(path):
return {}
spec = importlib.util.spec_from_file_location("_dispatch_db_software", path)
if spec is None or spec.loader is None:
return {}
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
raw = getattr(mod, "software_list_ru", {})
return {str(k): str(v) for k, v in dict(raw).items()}
def load_malfunction_list() -> dict[str, list[str]]:
"""Загрузить таблицу `software_id → список заголовков` из `DB_dispatch/4_malfunction_list.py`."""
path = os.path.join(_DB_DIR, "4_malfunction_list.py")
if not os.path.exists(path):
return {}
spec = importlib.util.spec_from_file_location("_dispatch_db_malfunction", path)
if spec is None or spec.loader is None:
return {}
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
raw = getattr(mod, "malfunction_list_ru", {})
return {str(k): [str(item) for item in list(values)] for k, values in dict(raw).items()}
def get_user_facility(user: dict | None) -> str:
"""Вернуть строку локации, привязанную к пользователю.
Источник связи — поле `facility_id` в `DB_dispatch/0_users.py` и
реестр `DEFAULT_BUTTON_LOCATIONS` из `DB_dispatch/2_customer_facility_list.py`.
Возвращает пустую строку, если пользователь не передан или связь
не настроена.
"""
if not user:
return ""
facility_id = str(user.get("facility_id", "")).strip()
if not facility_id:
return ""
locations = load_facility_locations()
return locations.get(facility_id, "")
def authenticate(login: str, password: str) -> tuple[dict | None, str]:
"""Проверить пару логин/пароль.
Возвращает ``(user_dict, "")`` при успехе или ``(None, error_code)``:
* ``"no_user"`` — логин не найден;
* ``"bad_password"`` — логин верный, пароль не совпадает.
"""
users = _load_users()
matched = [u for u in users if u.get("login") == login]
if not matched:
return None, "no_user"
if matched[0].get("password") == password:
return matched[0], ""
return None, "bad_password"
def write_session(user: dict) -> dict:
"""Записать сессию в `DB_dispatch/1_actual_state.py` и вернуть её."""
state = {
"state_id": str(uuid.uuid4()),
"user_id": user["user_id"],
"is_online": True,
"current_module": "dispatch",
"last_activity_ts": datetime.now(timezone.utc).isoformat(),
}
path = os.path.join(_DB_DIR, "1_actual_state.py")
with open(path, "w", encoding="utf-8") as fh:
fh.write("# -*- coding: utf-8 -*-\n")
fh.write("# DB_dispatch/1_actual_state.py\n\n")
fh.write(f"actual_state = [{repr(state)}]\n")
return state
def clear_session() -> None:
"""Сбросить активную сессию (выход из системы)."""
path = os.path.join(_DB_DIR, "1_actual_state.py")
with open(path, "w", encoding="utf-8") as fh:
fh.write("# -*- coding: utf-8 -*-\n")
fh.write("# DB_dispatch/1_actual_state.py\n\n")
fh.write("actual_state = []\n")
def load_session() -> dict | None:
"""Прочитать активную сессию и вернуть user-dict или None."""
path = os.path.join(_DB_DIR, "1_actual_state.py")
if not os.path.exists(path):
return None
spec = importlib.util.spec_from_file_location("_dispatch_db_state", path)
if spec is None or spec.loader is None:
return None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
states = getattr(mod, "actual_state", [])
if not states:
return None
user_id = states[0].get("user_id")
if not user_id:
return None
users = _load_users()
matched = [u for u in users if u.get("user_id") == user_id]
return matched[0] if matched else None

View File

@@ -0,0 +1,5 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
python main.py
pause

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# hub/ticket/domain/__init__.py
"""Доменные типы Ticket."""
from .ticket_types import (
ArchiveRecordSnapshot,
TicketConnectionStatus,
TicketDocumentSnapshot,
TicketHardwareStatus,
TicketTaskSnapshot,
)
from .location_catalog import LocationCatalog, parse_location_parts
from .task import TicketTask
from .ticket_constants import (
HARDWARE_SIGNAL_ADVANCE,
HARDWARE_SIGNAL_INITIALIZE,
TICKET_STATE_ACTIONS,
TICKET_STATE_COLORS,
TICKET_STATE_NAMES,
)
from .ticket_state_service import TicketStateService, TicketTransitionResult
from .ticket_transition_policy import TicketTransitionPolicy, TransitionDecision
__all__ = [
"ArchiveRecordSnapshot",
"HARDWARE_SIGNAL_ADVANCE",
"HARDWARE_SIGNAL_INITIALIZE",
"LocationCatalog",
"TicketDocumentSnapshot",
"TicketConnectionStatus",
"TicketHardwareStatus",
"TicketStateService",
"TicketTask",
"TicketTaskSnapshot",
"TicketTransitionPolicy",
"TicketTransitionResult",
"TICKET_STATE_ACTIONS",
"TICKET_STATE_COLORS",
"TICKET_STATE_NAMES",
"TransitionDecision",
"parse_location_parts",
]

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# hub/ticket/domain/location_catalog.py
"""Справочник соответствия button_id и локации Ticket."""
from __future__ import annotations
from typing import Mapping
DEFAULT_BUTTON_LOCATIONS = {
1: "ГБУЗ ФМБА ГП№1 (томограф Siemens, каб. 101)",
2: "ГБУЗ ФМБА ГП№2 (ангиограф Амико, каб. 205)",
3: "ГБУЗ ФМБА ГП№3 (рентген Philips, каб. 112)",
4: "ГБУЗ ФМБА ГП№4 (рентген Shimadzu, каб. 305)",
5: "ГБУЗ ФМБА ГП№5 (рентген Toshiba, каб. 208)",
6: "ГБУЗ ФМБА ГП№6 (рентген GE, каб. 410)",
7: "ГБУЗ ФМБА ГП№7 (рентген Canon, каб. 312)",
8: "ГБУЗ ФМБА ГП№8 (рентген Hitachi, каб. 415)",
}
class LocationCatalog:
"""Канонический справочник локаций Ticket."""
def __init__(self, locations: Mapping[int, str] | None = None):
self._locations = dict(DEFAULT_BUTTON_LOCATIONS)
if locations:
self._locations.update({int(key): value for key, value in locations.items()})
def get_location(self, button_id: int) -> str:
"""Вернуть локацию по button_id или fallback-описание."""
return self._locations.get(button_id, f"Неизвестная локация #{button_id}")
def parse_location_parts(location: str) -> tuple[str, str, str]:
"""Разобрать строку локации на учреждение, кабинет и оборудование."""
normalized_location = str(location or "").strip()
if not normalized_location:
return "", "", ""
if "(" not in normalized_location or ")" not in normalized_location:
return normalized_location, "", ""
institution = normalized_location.split("(", 1)[0].strip()
inside_brackets = normalized_location.split("(", 1)[1].split(")", 1)[0].strip()
if not inside_brackets:
return institution, "", ""
if "каб." not in inside_brackets:
return institution, "", inside_brackets
device_part, room_part = inside_brackets.split("каб.", 1)
room = f"каб. {room_part.strip().rstrip(',')}"
device = device_part.strip().rstrip(",")
return institution, room, device

View File

@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
# hub/ticket/domain/task.py
"""Доменная сущность задачи Ticket и сериализация её состояния."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Mapping
from .ticket_types import TicketTaskSnapshot
def _normalize_datetime(value: Any) -> datetime | None:
"""Преобразовать строку или datetime в datetime."""
if value is None or value == "":
return None
if isinstance(value, datetime):
return value
if not isinstance(value, str):
return None
try:
if "T" in value:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def _normalize_color(value: Any) -> str:
"""Нормализовать представление цвета до hex-строки."""
if isinstance(value, str) and value.startswith("#") and len(value) >= 4:
return value
name_getter = getattr(value, "name", None)
if callable(name_getter):
try:
normalized = name_getter()
if isinstance(normalized, str) and normalized.startswith("#"):
return normalized
except Exception as _exc:
return "#FFFFFF"
return "#FFFFFF"
def _normalize_int(value: Any, default: int | None = None) -> int | None:
"""Нормализовать число, сохранив валидное значение 0."""
if value is None or value == "":
return default
try:
return int(value)
except (TypeError, ValueError):
return default
@dataclass(slots=True)
class TicketTask:
"""Каноническая доменная задача Ticket."""
task_id: int
location: str
state_code: int
state_name: str
action_text: str = ""
color_hex: str = "#FFFFFF"
created_at: datetime | None = None
completed_at: datetime | None = None
refused_from_state: int | None = None
refusal_reason: str = ""
assigned_specialist: str = ""
specialist_photo: str = ""
diagnostic_report_signed: bool = False
repair_report_signed: bool = False
acceptance_report_signed: bool = False
sequence_number: int = 0
@classmethod
def from_record(cls, record: Mapping[str, Any]) -> TicketTask | None:
"""Создать задачу из записи файлового хранилища."""
raw_task_id = record.get("button_id")
if raw_task_id is None:
return None
try:
task_id = int(raw_task_id)
except (TypeError, ValueError):
return None
return cls(
task_id=task_id,
location=str(record.get("location", "")),
state_code=_normalize_int(record.get("state"), 1) or 0,
state_name=str(record.get("state_name", "")),
action_text=str(record.get("action", "")),
color_hex=_normalize_color(record.get("color", "#FFFFFF")),
created_at=_normalize_datetime(record.get("created_time")) or datetime.now(),
completed_at=_normalize_datetime(record.get("completed_time")),
refused_from_state=_normalize_int(record.get("refused_from_state")),
refusal_reason=str(record.get("refusal_reason", "")),
assigned_specialist=str(record.get("assigned_specialist", "")),
specialist_photo=str(record.get("specialist_photo", "")),
diagnostic_report_signed=bool(record.get("diagnostic_report_signed", False)),
repair_report_signed=bool(record.get("repair_report_signed", False)),
acceptance_report_signed=bool(record.get("acceptance_report_signed", False)),
sequence_number=_normalize_int(record.get("sequence_number"), 0) or 0,
)
@classmethod
def from_snapshot(cls, snapshot: TicketTaskSnapshot) -> TicketTask:
"""Создать доменную сущность из snapshot."""
return cls(
task_id=snapshot.task_id,
location=snapshot.location,
state_code=snapshot.state_code,
state_name=snapshot.state_name,
action_text=snapshot.action_text,
color_hex=snapshot.color_hex,
created_at=snapshot.created_at,
completed_at=snapshot.completed_at,
refused_from_state=snapshot.refused_from_state,
refusal_reason=snapshot.refusal_reason,
assigned_specialist=snapshot.assigned_specialist,
specialist_photo=snapshot.specialist_photo,
diagnostic_report_signed=snapshot.diagnostic_report_signed,
repair_report_signed=snapshot.repair_report_signed,
acceptance_report_signed=snapshot.acceptance_report_signed,
sequence_number=snapshot.sequence_number,
)
def to_record(self) -> dict[str, Any]:
"""Преобразовать доменную сущность в запись для JSON."""
return {
"button_id": self.task_id,
"location": self.location,
"state": self.state_code,
"action": self.action_text,
"state_name": self.state_name,
"color": self.color_hex,
"created_time": self.created_at,
"completed_time": self.completed_at,
"refused_from_state": self.refused_from_state,
"refusal_reason": self.refusal_reason,
"assigned_specialist": self.assigned_specialist,
"specialist_photo": self.specialist_photo,
"diagnostic_report_signed": self.diagnostic_report_signed,
"repair_report_signed": self.repair_report_signed,
"acceptance_report_signed": self.acceptance_report_signed,
"sequence_number": self.sequence_number,
}
def to_snapshot(self) -> TicketTaskSnapshot:
"""Вернуть неизменяемый снимок задачи для внешних слоёв."""
return TicketTaskSnapshot(
task_id=self.task_id,
location=self.location,
state_code=self.state_code,
state_name=self.state_name,
action_text=self.action_text,
color_hex=self.color_hex,
created_at=self.created_at,
completed_at=self.completed_at,
refused_from_state=self.refused_from_state,
refusal_reason=self.refusal_reason,
assigned_specialist=self.assigned_specialist,
specialist_photo=self.specialist_photo,
diagnostic_report_signed=self.diagnostic_report_signed,
repair_report_signed=self.repair_report_signed,
acceptance_report_signed=self.acceptance_report_signed,
sequence_number=self.sequence_number,
)

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# hub/ticket/domain/ticket_constants.py
"""Константы доменной state-machine Ticket."""
STATE_TODO = 1
STATE_IN_PROGRESS = 2
STATE_CONFIRMATION = 3
STATE_COMPLETED = 0
STATE_REFUSED = 4
STATE_ARCHIVED = 5
HARDWARE_SIGNAL_ADVANCE = frozenset({0, 1, 2, 3})
HARDWARE_SIGNAL_INITIALIZE = 0xFF
TICKET_STATE_NAMES = {
STATE_TODO: "К выполнению",
STATE_IN_PROGRESS: "В работе",
STATE_CONFIRMATION: "Подтверждение",
STATE_COMPLETED: "Выполненные",
STATE_REFUSED: "Отказ в обслуживании",
STATE_ARCHIVED: "Архив",
}
TICKET_STATE_ACTIONS = {
STATE_TODO: "Инженер направлен",
STATE_IN_PROGRESS: "Выполняются работы",
STATE_CONFIRMATION: "Ожидает подтверждения",
STATE_COMPLETED: "Работа завершена",
STATE_REFUSED: "Отказ в обслуживании",
STATE_ARCHIVED: "Перемещено в архив",
}
TICKET_STATE_COLORS = {
STATE_TODO: "#FF5938",
STATE_IN_PROGRESS: "#008BFA",
STATE_CONFIRMATION: "#FFD27A",
STATE_COMPLETED: "#36AC87",
STATE_REFUSED: "#D1D5DB",
STATE_ARCHIVED: "#9CA3AF",
}

View File

@@ -0,0 +1,208 @@
# -*- coding: utf-8 -*-
# hub/ticket/domain/ticket_state_service.py
"""Доменный сервис переходов и представления состояния Ticket."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from .location_catalog import LocationCatalog
from .task import TicketTask
from .ticket_constants import (
HARDWARE_SIGNAL_ADVANCE,
HARDWARE_SIGNAL_INITIALIZE,
STATE_ARCHIVED,
STATE_COMPLETED,
STATE_CONFIRMATION,
STATE_IN_PROGRESS,
STATE_REFUSED,
STATE_TODO,
TICKET_STATE_ACTIONS,
TICKET_STATE_COLORS,
TICKET_STATE_NAMES,
)
from .ticket_transition_policy import TicketTransitionPolicy
STATE_NAMES = TICKET_STATE_NAMES
STATE_COLORS = TICKET_STATE_COLORS
@dataclass(frozen=True, slots=True)
class TicketTransitionResult:
"""Результат обработки доменного перехода Ticket."""
changed: bool
task: TicketTask | None
message: str
blocked_reason: str = ""
class TicketStateService:
"""Каноническая state-machine Ticket без зависимости от GUI и COM-сервиса."""
def __init__(
self,
location_catalog: LocationCatalog | None = None,
transition_policy: TicketTransitionPolicy | None = None,
):
self._location_catalog = location_catalog or LocationCatalog()
self._transition_policy = transition_policy or TicketTransitionPolicy()
def handle_hardware_signal(
self,
button_id: int,
hardware_state: int,
current_task: TicketTask | None,
) -> TicketTransitionResult:
"""Обработать аппаратный сигнал и вычислить доменный результат."""
if hardware_state == HARDWARE_SIGNAL_INITIALIZE:
return TicketTransitionResult(
changed=False,
task=current_task,
message=f"Получен запрос инициализации для кнопки {button_id}.",
)
if hardware_state not in HARDWARE_SIGNAL_ADVANCE:
return TicketTransitionResult(
changed=False,
task=current_task,
message=f"Неизвестное аппаратное состояние: {hardware_state:02X}.",
blocked_reason="unsupported_hardware_signal",
)
return self.advance_task(button_id, current_task)
def advance_task(self, button_id: int, current_task: TicketTask | None) -> TicketTransitionResult:
"""Перевести задачу в следующее допустимое состояние."""
if current_task is None:
task = self._build_task_for_state(button_id, STATE_TODO)
return TicketTransitionResult(
changed=True,
task=task,
message=f"Создана новая задача #{button_id} в состоянии '{task.state_name}'.",
)
if current_task.state_code == STATE_ARCHIVED:
task = self._reset_task_cycle(current_task, STATE_TODO)
return TicketTransitionResult(
changed=True,
task=task,
message=f"Архивная задача #{button_id} начала новый цикл.",
)
if current_task.state_code == STATE_REFUSED:
task = self._reset_task_cycle(current_task, STATE_TODO)
return TicketTransitionResult(
changed=True,
task=task,
message=f"Задача #{button_id} сброшена из отказа в новое выполнение.",
)
if current_task.state_code == STATE_COMPLETED:
task = self._reset_task_cycle(current_task, STATE_TODO)
return TicketTransitionResult(
changed=True,
task=task,
message=f"Выполненная задача #{button_id} начала новый цикл.",
)
decision = self._transition_policy.can_advance(current_task)
if not decision.allowed:
return TicketTransitionResult(
changed=False,
task=current_task,
message=f"Переход задачи #{button_id} отклонён.",
blocked_reason=decision.reason,
)
next_state = self._next_state(current_task.state_code)
task = self._clone_with_state(current_task, next_state)
return TicketTransitionResult(
changed=True,
task=task,
message=(
f"Задача #{button_id} перешла из состояния "
f"'{STATE_NAMES[current_task.state_code]}' в '{task.state_name}'."
),
)
def mark_task_as_refused(
self,
button_id: int,
current_task: TicketTask | None,
refusal_reason: str = "",
) -> TicketTransitionResult:
"""Перевести задачу в состояние отказа."""
task = current_task or self._build_task_for_state(button_id, STATE_TODO)
refused_task = self._clone_with_state(task, STATE_REFUSED)
refused_task.refused_from_state = task.state_code
refused_task.refusal_reason = str(refusal_reason or "").strip()
return TicketTransitionResult(
changed=True,
task=refused_task,
message=f"Задача #{button_id} переведена в отказ.",
)
def move_task_to_archive(
self,
button_id: int,
current_task: TicketTask | None,
) -> TicketTransitionResult:
"""Переместить задачу в архив как отдельное доменное правило."""
task = current_task or self._build_task_for_state(button_id, STATE_TODO)
archived_task = self._clone_with_state(task, STATE_ARCHIVED)
return TicketTransitionResult(
changed=True,
task=archived_task,
message=f"Задача #{button_id} перемещена в архив.",
)
def _next_state(self, current_state: int) -> int:
if current_state == STATE_TODO:
return STATE_IN_PROGRESS
if current_state == STATE_IN_PROGRESS:
return STATE_CONFIRMATION
if current_state == STATE_CONFIRMATION:
return STATE_COMPLETED
return current_state
def _build_task_for_state(self, button_id: int, state_code: int) -> TicketTask:
"""Создать новую задачу для button_id в заданном состоянии."""
return TicketTask(
task_id=button_id,
location=self._location_catalog.get_location(button_id),
state_code=state_code,
state_name=STATE_NAMES[state_code],
action_text=TICKET_STATE_ACTIONS[state_code],
color_hex=STATE_COLORS[state_code],
created_at=datetime.now(),
completed_at=datetime.now() if state_code == STATE_COMPLETED else None,
)
def _clone_with_state(self, task: TicketTask, state_code: int) -> TicketTask:
"""Склонировать задачу с новым доменным состоянием."""
return TicketTask(
task_id=task.task_id,
location=task.location,
state_code=state_code,
state_name=STATE_NAMES[state_code],
action_text=TICKET_STATE_ACTIONS[state_code],
color_hex=STATE_COLORS[state_code],
created_at=task.created_at,
completed_at=datetime.now() if state_code == STATE_COMPLETED else task.completed_at,
refused_from_state=task.refused_from_state,
refusal_reason=task.refusal_reason,
assigned_specialist=task.assigned_specialist,
specialist_photo=task.specialist_photo,
diagnostic_report_signed=task.diagnostic_report_signed,
repair_report_signed=task.repair_report_signed,
acceptance_report_signed=task.acceptance_report_signed,
sequence_number=task.sequence_number,
)
def _reset_task_cycle(self, task: TicketTask, state_code: int) -> TicketTask:
"""Начать новый цикл задачи на основе архивной или отказной записи."""
fresh_task = self._build_task_for_state(task.task_id, state_code)
fresh_task.location = task.location
return fresh_task

View File

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# hub/ticket/domain/ticket_transition_policy.py
"""Политика допусков переходов между состояниями Ticket."""
from __future__ import annotations
from dataclasses import dataclass
from .task import TicketTask
from .ticket_constants import (
STATE_COMPLETED,
STATE_CONFIRMATION,
STATE_IN_PROGRESS,
STATE_TODO,
)
@dataclass(frozen=True, slots=True)
class TransitionDecision:
"""Результат проверки допуска перехода."""
allowed: bool
reason: str = ""
class TicketTransitionPolicy:
"""Доменные проверки допуска переходов Ticket."""
def can_advance(self, task: TicketTask) -> TransitionDecision:
"""Проверить возможность перехода в следующее состояние."""
if task.state_code == STATE_TODO and not task.assigned_specialist.strip():
return TransitionDecision(
allowed=False,
reason="Без назначенного специалиста нельзя перейти в 'В работе'.",
)
if task.state_code == STATE_IN_PROGRESS:
if not task.diagnostic_report_signed or not task.repair_report_signed:
return TransitionDecision(
allowed=False,
reason="Без двух подписанных отчётов нельзя перейти в 'Подтверждение'.",
)
if task.state_code == STATE_CONFIRMATION and not task.acceptance_report_signed:
return TransitionDecision(
allowed=False,
reason="Без акта приёмки нельзя перейти в 'Выполненные'.",
)
if task.state_code == STATE_COMPLETED:
return TransitionDecision(
allowed=False,
reason="Выполненная задача не должна принимать лишние сигналы до архивации.",
)
return TransitionDecision(allowed=True)

View File

@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
# hub/ticket/domain/ticket_types.py
"""Базовые доменные типы Ticket без зависимости от legacy GUI."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Mapping
class TicketConnectionStatus(str, Enum):
"""Нормализованный статус аппаратного подключения Ticket."""
CONNECTED = "connected"
DISCONNECTED = "disconnected"
ERROR = "error"
@dataclass(frozen=True, slots=True)
class TicketTaskSnapshot:
"""Плоский снимок задачи для обмена между слоями."""
task_id: int
location: str
state_code: int
state_name: str
action_text: str = ""
color_hex: str = "#FFFFFF"
created_at: datetime | None = None
completed_at: datetime | None = None
refused_from_state: int | None = None
refusal_reason: str = ""
assigned_specialist: str = ""
specialist_photo: str = ""
diagnostic_report_signed: bool = False
repair_report_signed: bool = False
acceptance_report_signed: bool = False
sequence_number: int = 0
@dataclass(frozen=True, slots=True)
class TicketDocumentSnapshot:
"""Плоский снимок сохранённого документа Ticket."""
document_id: str
task_id: int
document_type: str
title: str
created_at: datetime
location: str = ""
specialist_name: str = ""
summary: str = ""
content: str = ""
storage_path: str = ""
payload: Mapping[str, str] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class TicketHardwareStatus:
"""Снимок статуса аппаратного шлюза."""
connection_status: TicketConnectionStatus = TicketConnectionStatus.DISCONNECTED
message: str = ""
buttons_initialized: bool = False
button_count: int = 0
@dataclass(frozen=True, slots=True)
class ArchiveRecordSnapshot:
"""Плоский снимок архивной записи по завершённой или отказной задаче."""
task_id: int
location: str
pre_archive_state_code: int
pre_archive_state_name: str
color_hex: str = "#FFFFFF"
created_at: datetime | None = None
completed_at: datetime | None = None
archived_at: datetime | None = None
refused_from_state: int | None = None
refusal_reason: str = ""
assigned_specialist: str = ""
specialist_photo: str = ""
diagnostic_report_signed: bool = False
repair_report_signed: bool = False
acceptance_report_signed: bool = False
document_ids: tuple[str, ...] = ()
action_text: str = ""
sequence_number: int = 0

View File

@@ -0,0 +1,268 @@
# -*- coding: utf-8 -*-
# error_logger.py
"""Централизованный модуль логирования ошибок.
Записывает ошибки в logs/error.log с ротацией и идентификацией источника.
Использование
-------------
::
from error_logger import setup_error_logging, log_exception
# Один раз при старте приложения:
setup_error_logging()
# В except-блоках:
except Exception as e:
log_exception(__name__, "func_name", e)
Формат записи лога
------------------
::
2026-03-27 14:30:00 | ERROR | module | function | ExcType | message
Traceback (most recent call last):
...
"""
from __future__ import annotations
import faulthandler
import logging
import sys
import threading
import traceback
from logging.handlers import RotatingFileHandler
from pathlib import Path
_LOG_DIR = Path(__file__).resolve().parent / "logs"
_LOG_FILE = _LOG_DIR / "error.log"
_INTERPRETER_LOG_FILE = _LOG_DIR / "interpreter.log"
_MAX_BYTES = 5 * 1024 * 1024
_BACKUP_COUNT = 3
_error_logger: logging.Logger = logging.getLogger("usms.errors")
_configured = False
_interpreter_hooks_installed = False
_interpreter_stream = None
class _SafeRotatingFileHandler(RotatingFileHandler):
"""Rotating handler, устойчивый к lock-ошибкам Windows при rollover."""
def handleError(self, record: logging.LogRecord) -> None:
exc = sys.exc_info()[1]
if not _is_locked_rollover_error(exc):
super().handleError(record)
return
self._reopen_stream()
try:
logging.FileHandler.emit(self, record)
except Exception as exc:
try:
sys.__stderr__.write(f"error_logger emit failure: {type(exc).__name__}: {exc}\n")
sys.__stderr__.flush()
except Exception as _fallback_exc:
return
return
def _reopen_stream(self) -> None:
try:
if self.stream is not None:
self.stream.close()
except Exception as exc:
try:
sys.__stderr__.write(f"error_logger stream close failure: {type(exc).__name__}: {exc}\n")
sys.__stderr__.flush()
except Exception as _fallback_exc:
return
self.stream = self._open()
def _is_locked_rollover_error(exc: BaseException | None) -> bool:
"""Определить отказ rollover из-за блокировки log-файла на Windows."""
if not isinstance(exc, PermissionError):
return False
return getattr(exc, "winerror", None) == 32
def setup_error_logging() -> None:
"""Настроить файловый handler для логирования ошибок.
Создаёт директорию ``logs/`` и добавляет ``RotatingFileHandler``
к логгеру ``usms``, чтобы все дочерние логгеры
(``usms.warehouse``, ``usms.errors`` и т.д.) автоматически
записывали ошибки уровня ERROR и выше в файл.
"""
global _configured
if _configured:
return
_LOG_DIR.mkdir(parents=True, exist_ok=True)
usms_root = logging.getLogger("usms")
if any(
isinstance(handler, _SafeRotatingFileHandler)
and Path(getattr(handler, "baseFilename", "")) == _LOG_FILE
for handler in usms_root.handlers
):
_configured = True
return
file_handler = _SafeRotatingFileHandler(
str(_LOG_FILE),
maxBytes=_MAX_BYTES,
backupCount=_BACKUP_COUNT,
encoding="utf-8",
)
file_handler.setLevel(logging.ERROR)
formatter = logging.Formatter(
"%(asctime)s | %(levelname)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler.setFormatter(formatter)
usms_root.addHandler(file_handler)
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setLevel(logging.ERROR)
console_handler.setFormatter(formatter)
usms_root.addHandler(console_handler)
if usms_root.level == logging.NOTSET or usms_root.level > logging.DEBUG:
usms_root.setLevel(logging.DEBUG)
_configured = True
def install_interpreter_hooks() -> None:
"""Подключить глобальные hooks для traceback и фатальных ошибок."""
global _interpreter_hooks_installed, _interpreter_stream
if _interpreter_hooks_installed:
return
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_interpreter_stream = _INTERPRETER_LOG_FILE.open("a", encoding="utf-8")
def _handle_unhandled_exception(
exc_type: type[BaseException],
exc_value: BaseException,
exc_tb,
) -> None:
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_tb)
return
formatted = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
_write_interpreter_diagnostics("UNHANDLED EXCEPTION", formatted)
_error_logger.error(
"%s | %s | %s\n%s",
"__main__",
"sys.excepthook",
exc_type.__name__,
formatted.rstrip(),
)
def _handle_thread_exception(args: threading.ExceptHookArgs) -> None:
formatted = "".join(
traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)
)
_write_interpreter_diagnostics(
f"THREAD EXCEPTION: {getattr(args.thread, 'name', 'unknown')}",
formatted,
)
_error_logger.error(
"%s | %s | %s\n%s",
"__main__",
"threading.excepthook",
args.exc_type.__name__,
formatted.rstrip(),
)
def _handle_unraisable(unraisable) -> None:
formatted = "".join(
traceback.format_exception(
type(unraisable.exc_value),
unraisable.exc_value,
unraisable.exc_traceback,
)
)
object_repr = repr(getattr(unraisable, "object", None))
_write_interpreter_diagnostics(
f"UNRAISABLE EXCEPTION: {object_repr}",
formatted,
)
_error_logger.error(
"%s | %s | %s\n%s",
"__main__",
"sys.unraisablehook",
type(unraisable.exc_value).__name__,
formatted.rstrip(),
)
sys.excepthook = _handle_unhandled_exception
threading.excepthook = _handle_thread_exception
if hasattr(sys, "unraisablehook"):
sys.unraisablehook = _handle_unraisable
try:
faulthandler.enable(file=_interpreter_stream, all_threads=True)
except Exception as exc:
try:
sys.__stderr__.write(f"faulthandler enable failure: {type(exc).__name__}: {exc}\n")
sys.__stderr__.flush()
except Exception as _fallback_exc:
return
_interpreter_hooks_installed = True
def _write_interpreter_diagnostics(title: str, payload: str) -> None:
"""Вывести интерпретаторную диагностику и в консоль, и в файл."""
message = f"\n=== {title} ===\n{payload}"
try:
sys.__stderr__.write(message)
sys.__stderr__.flush()
except Exception as exc:
try:
sys.stderr.write(f"interpreter diagnostics stderr failure: {type(exc).__name__}: {exc}\n")
sys.stderr.flush()
except Exception as _fallback_exc:
return
try:
if _interpreter_stream is not None:
_interpreter_stream.write(message)
_interpreter_stream.flush()
except Exception as exc:
try:
sys.__stderr__.write(f"interpreter log file write failure: {type(exc).__name__}: {exc}\n")
sys.__stderr__.flush()
except Exception as _fallback_exc:
return
def log_exception(module: str, func: str, exc: BaseException) -> None:
"""Залогировать перехваченное исключение с идентификацией источника.
Parameters
----------
module : str
Имя модуля (обычно ``__name__``).
func : str
Имя функции / метода, в котором произошла ошибка.
exc : BaseException
Перехваченное исключение.
"""
exc_type = type(exc).__name__
if exc.__traceback__ is not None:
formatted_traceback = "".join(
traceback.format_exception(type(exc), exc, exc.__traceback__)
)
else:
formatted_traceback = f"{exc_type}: {exc}"
_error_logger.error(
"%s | %s | %s | %s\n%s",
module,
func,
exc_type,
exc,
formatted_traceback,
)

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# gui/__init__.py
from __future__ import annotations
from .styles import APP_STYLES
from .components.button import Button
from .components.label import Label
def __getattr__(name: str):
"""Лениво отдать `MainWindow`, не создавая цикл `gui -> window -> hub -> gui`."""
if name == "MainWindow":
from .window import MainWindow
return MainWindow
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
'MainWindow',
'APP_STYLES',
'Button',
'Label',
]
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Пакетный __init__.py для gui/. Реэкспортирует ключевые классы
# для удобного импорта: MainWindow, APP_STYLES, Button, Label.
#
# 2) Зависимости модуля:
# Реимпорт из: styles (APP_STYLES), components.button (Button),
# components.label (Label). MainWindow импортируется лениво через
# __getattr__, чтобы не создавать цикл с hub во время plugin-import.
#
# 3) Экспорт (__all__):
# MainWindow, Button, Label.
# Также доступен APP_STYLES (не в __all__, но импортируется).

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# gui/components/__init__.py
"""Компоненты пользовательского интерфейса."""
from .button import Button
from .dialog import Dialog
from .label import Label
from .text_input import TextInput
from .coordinate_input import CoordinateInput
from .combo_box import ComboBox
from .double_spin_box import DoubleSpinBox
from .radio_button import RadioButton
from .radio_group import RadioGroup
from .toggle_button import ToggleButton
from .tab_button import TabButton
from .tab_widget import TabWidget
from .topology_tree_widget import TopologyTreeWidget
from .model_view_widget import ModelViewWidget
from .part_visualizer import PartVisualizer
from .photo_view_widget import PhotoViewWidget
from .springs import VSpring, HSpring
from .group_box import GroupBox
from .color_swatch import ColorSwatch
from .color_palette import ColorPalette
from .kanban_board import KanbanBoard, KanbanColumn, KanbanCard
__all__ = [
'Button',
'Dialog',
'Label',
'TextInput',
'CoordinateInput',
'ComboBox',
'DoubleSpinBox',
'RadioButton',
'RadioGroup',
'ToggleButton',
'TabButton',
'TabWidget',
'TopologyTreeWidget',
'ModelViewWidget',
'PartVisualizer',
'PhotoViewWidget',
'VSpring',
'HSpring',
'GroupBox',
'ColorSwatch',
'ColorPalette',
'KanbanBoard',
'KanbanColumn',
'KanbanCard',
]
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Пакетный __init__: реэкспорт всех публичных UI-компонентов проекта
# из единой точки входа gui.components.
#
# 2) Зависимости модуля:
# Импортирует все компоненты: Button, Label, TextInput, CoordinateInput,
# ComboBox, DoubleSpinBox, RadioButton, RadioGroup, ToggleButton,
# TabButton, TabWidget, TopologyTreeWidget, ModelViewWidget,
# PartVisualizer, PhotoViewWidget, VSpring, HSpring, GroupBox,
# ColorSwatch, ColorPalette.
#
# 3) Экспорт:
# __all__ — список из 20 публичных символов.
# Потребители импортируют: from gui.components import Button, Label, ...

View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
# gui/components/_tree_node_building.py
"""Сервис построения узлов дерева и ленивой загрузки (композиция)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Dict, Any
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QTreeWidgetItem
if TYPE_CHECKING:
from gui.components.topology_tree_widget import TopologyTreeWidget
class TreeNodeBuilder:
"""Создание элементов QTreeWidget и ленивая загрузка дочерних узлов."""
def __init__(self, host: "TopologyTreeWidget") -> None:
self._host = host
def load_root_nodes(self) -> None:
"""Загрузка корневых узлов (сайтов)."""
try:
root_nodes = self._host.data_loader('site', None)
for node_data in root_nodes:
item = self._create_tree_item(node_data)
self._host._tree.addTopLevelItem(item)
if node_data.has_children and not node_data.children_loaded:
stub = QTreeWidgetItem(["Загрузка..."])
stub.setData(0, Qt.UserRole, {"is_stub": True})
item.addChild(stub)
except Exception as e:
error_msg = f"Ошибка загрузки корневых узлов: {e}"
print(error_msg)
self._host.dataLoadError.emit(error_msg)
def _create_tree_item(self, node_data) -> QTreeWidgetItem:
"""Создание элемента дерева на основе данных узла."""
item = QTreeWidgetItem()
display_attrs = self._host.DISPLAY_ATTRIBUTES.get(node_data.node_type, [])
col_values = self._get_column_values(
node_data.display_data, display_attrs, node_data.node_type,
)
for i, value in enumerate(col_values):
if i < self._host._tree.columnCount():
item.setText(i, str(value))
item.setData(0, Qt.UserRole, {
'type': node_data.node_type,
'id': node_data.node_id,
'display_data': node_data.display_data,
'has_children': node_data.has_children,
'children_loaded': node_data.children_loaded,
'raw_data': node_data.raw_data,
})
item.setChildIndicatorPolicy(
QTreeWidgetItem.ShowIndicator if node_data.has_children
else QTreeWidgetItem.DontShowIndicator
)
return item
@staticmethod
def _get_column_values(
data: Dict[str, Any], attrs: list[str], node_type: str,
) -> list[str]:
"""Значения для колонок (только значения атрибутов, без ключей)."""
values = []
if attrs:
for attr in attrs:
if attr in data and data[attr]:
values.append(str(data[attr]))
break
else:
values.append("")
else:
values.append("")
if len(attrs) > 1:
other = [str(data[a]) for a in attrs[1:] if a in data and data[a]]
sep = " " if node_type == "shelf" else ", "
values.append(sep.join(other))
else:
values.append("")
return values
def load_children(self, parent_item: QTreeWidgetItem) -> None:
"""Ленивая загрузка дочерних элементов для узла."""
parent_data = parent_item.data(0, Qt.UserRole)
if not parent_data:
return
parent_type = parent_data['type']
parent_id = parent_data['id']
node_key = f"{parent_type}:{parent_id}"
if node_key in self._host._loading_nodes:
return
self._host._loading_nodes.add(node_key)
try:
if parent_item.childCount() == 1:
child = parent_item.child(0)
child_data = child.data(0, Qt.UserRole) if child else {}
if child_data and child_data.get('is_stub'):
parent_item.removeChild(child)
children_data = self._host.data_loader(
self._get_child_type(parent_type), parent_id,
)
for child_data in children_data:
child_item = self._create_tree_item(child_data)
parent_item.addChild(child_item)
if child_data.has_children and not child_data.children_loaded:
stub = QTreeWidgetItem(["Загрузка..."])
stub.setData(0, Qt.UserRole, {"is_stub": True})
child_item.addChild(stub)
parent_data['children_loaded'] = True
parent_item.setData(0, Qt.UserRole, parent_data)
self._host._tree.resizeColumnToContents(0)
except Exception as e:
msg = f"Ошибка загрузки дочерних элементов для {parent_type} {parent_id}: {e}"
print(msg)
self._host.dataLoadError.emit(msg)
err_item = QTreeWidgetItem(["Ошибка загрузки", str(e)[:50]])
err_item.setData(0, Qt.UserRole, {"is_error": True})
parent_item.addChild(err_item)
finally:
self._host._loading_nodes.remove(node_key)
@staticmethod
def _get_child_type(parent_type: str) -> str:
"""Тип дочерних элементов на основе типа родителя."""
hierarchy = {
'site': 'facility', 'facility': 'zone', 'zone': 'rack',
'rack': 'shelf', 'shelf': 'cell', 'cell': 'volume',
}
return hierarchy.get(parent_type, '')

View File

@@ -0,0 +1,235 @@
# -*- coding: utf-8 -*-
# gui/components/_tree_state_management.py
"""Сервис управления состоянием дерева: поиск, раскрытие, перезагрузка (композиция)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QTreeWidgetItem, QAbstractItemView
from error_logger import log_exception
if TYPE_CHECKING:
from gui.components.topology_tree_widget import TopologyTreeWidget
class TreeStateManager:
"""Поиск узлов, снимок/восстановление раскрытия, перезагрузка дерева."""
def __init__(self, host: "TopologyTreeWidget") -> None:
self._host = host
def refresh_node(self, node_type: str, node_id: str) -> None:
"""Обновление данных узла и его детей."""
found_item = self._find_item_by_data(node_type, node_id)
if found_item:
while found_item.childCount() > 0:
found_item.removeChild(found_item.child(0))
item_data = found_item.data(0, Qt.UserRole)
if item_data:
item_data['children_loaded'] = False
found_item.setData(0, Qt.UserRole, item_data)
if item_data.get('has_children'):
stub = QTreeWidgetItem(["Загрузка..."])
stub.setData(0, Qt.UserRole, {"is_stub": True})
found_item.addChild(stub)
self._host._tree.resizeColumnToContents(0)
def _find_item_by_data(self, node_type: str, node_id: str) -> Optional[QTreeWidgetItem]:
"""Поиск элемента дерева по типу и ID."""
def search(item: QTreeWidgetItem) -> Optional[QTreeWidgetItem]:
d = item.data(0, Qt.UserRole)
if d and d.get('type') == node_type and d.get('id') == node_id:
return item
for i in range(item.childCount()):
result = search(item.child(i))
if result:
return result
return None
for i in range(self._host._tree.topLevelItemCount()):
result = search(self._host._tree.topLevelItem(i))
if result:
return result
return None
def _find_or_load_item_by_data(
self, node_type: str, node_id: str,
) -> Optional[QTreeWidgetItem]:
"""Найти узел, лениво подгружая и раскрывая ветки."""
node_type = str(node_type or "")
node_id = str(node_id or "")
if not node_type or not node_id:
return None
found = self._find_item_by_data(node_type, node_id)
if found is not None:
return found
def search(item: QTreeWidgetItem) -> Optional[QTreeWidgetItem]:
d = item.data(0, Qt.UserRole) or {}
if d.get("is_stub") or d.get("is_error"):
return None
ct = str(d.get("type") or "")
ci = str(d.get("id") or "")
if ct == node_type and ci == node_id:
return item
if ct == node_type and ci != node_id:
return None
if bool(d.get("has_children")) and not bool(d.get("children_loaded")):
try:
self._host._node_builder.load_children(item)
except Exception as e:
log_exception(__name__, "_find_or_load.load_children", e)
for idx in range(item.childCount()):
result = search(item.child(idx))
if result is not None:
try:
item.setExpanded(True)
except Exception as e:
log_exception(__name__, "_find_or_load.setExpanded", e)
return result
return None
for idx in range(self._host._tree.topLevelItemCount()):
result = search(self._host._tree.topLevelItem(idx))
if result is not None:
return result
return None
@staticmethod
def _expand_item_parents(item: QTreeWidgetItem) -> None:
"""Раскрыть цепочку родителей до корня."""
parent = item.parent()
while parent is not None:
parent.setExpanded(True)
parent = parent.parent()
def clear_tree(self) -> None:
"""Полная очистка дерева."""
self._host._tree.clear()
self._host._loading_nodes.clear()
@staticmethod
def _item_key(item: QTreeWidgetItem) -> tuple[str, str] | None:
"""Уникальный ключ узла: (type, id)."""
data = item.data(0, Qt.UserRole) or {}
nt = str(data.get("type") or "")
ni = str(data.get("id") or "")
if not nt or not ni:
return None
if data.get("is_stub") or data.get("is_error"):
return None
return nt, ni
def _collect_expanded_paths(self) -> list[list[tuple[str, str]]]:
"""Снять снимок раскрытых узлов как пути от корня."""
paths: list[list[tuple[str, str]]] = []
def walk(item: QTreeWidgetItem, path: list[tuple[str, str]]) -> None:
key = self._item_key(item)
if key is None:
return
path = [*path, key]
if item.isExpanded():
paths.append(path)
for idx in range(item.childCount()):
walk(item.child(idx), path)
for idx in range(self._host._tree.topLevelItemCount()):
walk(self._host._tree.topLevelItem(idx), [])
return paths
def _find_child_by_key(self, parent: QTreeWidgetItem, key: tuple[str, str]) -> Optional[QTreeWidgetItem]:
for idx in range(parent.childCount()):
child = parent.child(idx)
if self._item_key(child) == key:
return child
return None
def _find_root_by_key(self, key: tuple[str, str]) -> Optional[QTreeWidgetItem]:
for idx in range(self._host._tree.topLevelItemCount()):
item = self._host._tree.topLevelItem(idx)
if self._item_key(item) == key:
return item
return None
def _expand_path(self, path: list[tuple[str, str]]) -> None:
"""Раскрыть путь root->... с подзагрузкой ленивых детей."""
if not path:
return
current = self._find_root_by_key(path[0])
if current is None:
return
current.setExpanded(True)
for key in path[1:]:
data = current.data(0, Qt.UserRole) or {}
if data.get("has_children") and not data.get("children_loaded"):
self._host._node_builder.load_children(current)
child = self._find_child_by_key(current, key)
if child is None:
return
child.setExpanded(True)
current = child
def _restore_tree_state(
self,
expanded_paths: list[list[tuple[str, str]]],
selected_key: tuple[str, str] | None,
) -> None:
"""Восстановить раскрытие и выбранный элемент после reload."""
for path in expanded_paths:
self._expand_path(path)
if selected_key:
sel = self._find_item_by_data(selected_key[0], selected_key[1])
if sel is not None:
self._host._tree.setCurrentItem(sel)
self._host._tree.scrollToItem(
sel, QAbstractItemView.ScrollHint.PositionAtCenter,
)
def reload_tree(self, preserve_state: bool = True) -> None:
"""Полная перезагрузка дерева с опциональным сохранением состояния."""
expanded_paths: list[list[tuple[str, str]]] = []
selected_key: tuple[str, str] | None = None
if preserve_state:
expanded_paths = self._collect_expanded_paths()
current = self._host._tree.currentItem()
if current is not None:
selected_key = self._item_key(current)
self.clear_tree()
self._host._node_builder.load_root_nodes()
if preserve_state:
self._restore_tree_state(expanded_paths, selected_key)
self._host._tree.resizeColumnToContents(0)
def select_node(
self,
node_type: str,
node_id: str,
*,
emit_selected: bool = False,
allow_load: bool = True,
expand_parents: bool = True,
) -> bool:
"""Программно выделить узел дерева. Возвращает True при успехе."""
rt = str(node_type or "")
ri = str(node_id or "")
if allow_load:
item = self._find_or_load_item_by_data(rt, ri)
else:
item = self._find_item_by_data(rt, ri)
if item is None:
return False
if expand_parents:
self._expand_item_parents(item)
self._host._tree.setCurrentItem(item)
self._host._tree.scrollToItem(
item, QAbstractItemView.ScrollHint.PositionAtCenter,
)
if emit_selected:
d = item.data(0, Qt.UserRole) or {}
if not d.get("is_stub") and not d.get("is_error"):
self._host.nodeSelected.emit(
str(d.get("type") or ""),
str(d.get("id") or ""),
dict(d.get("display_data") or {}),
)
return True

View File

@@ -0,0 +1,280 @@
# -*- coding: utf-8 -*-
# gui/components/button.py
from PySide6.QtWidgets import QPushButton, QSizePolicy
from PySide6.QtCore import Slot, QSize
from PySide6.QtGui import QIcon
from gui.theme_bus import theme_bus
from gui.containers.s_container import SContainer # Импортируем кастомный контейнер
from gui.styles import APP_STYLES
class Button(SContainer):
"""Навигационная кнопка на основе кастомного контейнера SContainer."""
def __init__(self, text: str, index: int = 0, **kwargs):
# Извлекаем параметры для передачи в SContainer
width_percent = kwargs.get("width_percent", None)
height_percent = kwargs.get("height_percent", None)
margin = kwargs.get("margin", [0, 2, 0, 2])
style = kwargs.get("style", None)
active_style = kwargs.get("active_style", None)
is_active = kwargs.get("is_active", None)
content_fit = kwargs.get("content_fit", True)
parent = kwargs.get("parent", None)
# Вызываем конструктор SContainer с параметрами
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
style=style,
active_style=active_style,
is_active=is_active,
content_fit=content_fit,
parent=parent,
)
self.index = index
self._theme = "dark"
self._is_active = False
self._style_key_normal = None
self._style_key_active = None
# Создаем кнопку
self._button = QPushButton(text)
self._button.setProperty("widget_index", index)
# Добавляем кнопку в layout контейнера
super().add_widget(self._button)
# Настраиваем кнопку для заполнения всего доступного пространства
self._button.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Expanding
)
# Флаг для отслеживания первого обновления
self._initial_update_done = False
if style is not None:
self._style_key_normal = style
self._style_key_active = active_style or style
if is_active is not None:
self._is_active = bool(is_active)
self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light"
self.style()
theme_bus.theme_changed.connect(self.set_theme)
# Иконка (опционально): путь к PNG + размер иконки внутри кнопки.
# Размер иконки — это свойство QPushButton (QSize), а не layout-геометрия;
# правило 6.7 (запрет fixed-size) распространяется на разметку, не на иконки.
icon_path = kwargs.get("icon_path", None)
icon_size = kwargs.get("icon_size", 16)
if icon_path:
self._button.setIcon(QIcon(str(icon_path)))
self._button.setIconSize(QSize(int(icon_size), int(icon_size)))
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
):
"""Короткий метод применения стиля. Можно задать ключи и активность явно."""
if style_key is not None:
self._style_key_normal = style_key
self._style_key_active = active_key or style_key
if is_active is not None:
self._is_active = bool(is_active)
if self._style_key_normal is not None:
active_key = self._style_key_active or self._style_key_normal
key = active_key if self._is_active else self._style_key_normal
themed = f"{key}_{self._theme.upper()}"
if themed in APP_STYLES:
key = themed
self._button.setStyleSheet(APP_STYLES.get(key, ""))
return
if self._theme == "light":
if self._is_active and "STANDARD_BUTTON_LIGHT_THEME_ACTIVE" in APP_STYLES:
self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_LIGHT_THEME_ACTIVE"])
else:
self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_LIGHT_THEME"])
return
if self._is_active:
self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_DARK_THEME_ACTIVE"])
else:
self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_DARK_THEME"])
@Slot(str)
def set_theme(self, theme: str):
"""Внешний слот: принимает 'dark' или 'light'."""
theme = (theme or "").strip().lower()
if theme not in ("dark", "light"):
return # игнорируем ошибочные значения
if self._theme == theme:
return
self._theme = theme
self.style()
# Делегируем clicked сигнал и другие методы внутренней кнопке
@property
def clicked(self):
return self._button.clicked
@property
def toggled(self):
return self._button.toggled
def click(self):
self._button.click()
def set_text(self, text: str):
self._button.setText(text)
def get_text(self) -> str:
return self._button.text()
def set_tooltip(self, text: str):
self._button.setToolTip(text)
def get_tooltip(self) -> str:
return self._button.toolTip()
def set_checkable(self, checkable: bool):
self._button.setCheckable(checkable)
def set_checked(self, checked: bool):
self._button.setChecked(checked)
def is_checked(self) -> bool:
return self._button.isChecked()
def set_enabled(self, enabled: bool):
self._button.setEnabled(enabled)
super().setEnabled(enabled)
def set_min_width(self, width: int) -> None:
self._button.setMinimumWidth(width)
super().setMinimumWidth(width)
def set_min_height(self, height: int) -> None:
self._button.setMinimumHeight(height)
super().setMinimumHeight(height)
def set_max_width(self, width: int) -> None:
self._button.setMaximumWidth(width)
super().setMaximumWidth(width)
def set_max_height(self, height: int) -> None:
self._button.setMaximumHeight(height)
super().setMaximumHeight(height)
def set_fixed_size(self, width: int, height: int) -> None:
self._button.setMinimumSize(width, height)
self._button.setMaximumSize(width, height)
super().setMinimumSize(width, height)
super().setMaximumSize(width, height)
def set_font(self, font):
self._button.setFont(font)
def set_property(self, name: str, value):
super().setProperty(name, value)
self._button.setProperty(name, value)
def set_size_policy(self, horizontal, vertical) -> None:
self._button.setSizePolicy(horizontal, vertical)
super().setSizePolicy(horizontal, vertical)
# Переопределяем add_widget, чтобы предотвратить добавление других виджетов
def add_widget(self, widget, alignment=None):
raise NotImplementedError("Button может содержать только одну кнопку")
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Навигационная/функциональная кнопка, реализованная как запечатанный
# контейнер SContainer с одной внутренней QPushButton, поддерживающая
# централизованные стили APP_STYLES и автоматическое переключение
# тем (dark/light) через theme_bus.
#
# 2) Зависимости модуля:
# Импорты: QPushButton, QSizePolicy (PySide6.QtWidgets),
# Slot (PySide6.QtCore),
# theme_bus (gui.theme_bus),
# SContainer (gui.containers.s_container),
# APP_STYLES (gui.styles)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс Button — публичный виджет-кнопка.
# Основные методы: style(), set_theme(), set_text(), get_text(),
# set_tooltip(), set_checkable(), set_checked(), is_checked(),
# set_enabled(), set_min_width/height(), set_max_width/height(),
# set_fixed_size(), set_font(), set_property(), set_size_policy(),
# click().
# Свойства: clicked, toggled (делегируют к внутренней QPushButton).
#
# 4) Состояние (поля):
# index: int — числовой индекс кнопки (для идентификации в группе)
# _theme: str — текущая тема ("dark" | "light")
# _is_active: bool — признак активного состояния
# _style_key_normal: str|None — ключ стиля нормального состояния
# _style_key_active: str|None — ключ стиля активного состояния
# _button: QPushButton — внутренний виджет кнопки
# _initial_update_done: bool — флаг первого обновления
#
# 5) Последовательность действий и вызовов:
# __init__(text, index, **kwargs)
# -> super().__init__(...) — инициализация SContainer
# -> QPushButton(text) — создание внутренней кнопки
# -> super().add_widget(_button) — добавление в layout контейнера
# -> style() — первичное применение стиля из APP_STYLES
# -> theme_bus.theme_changed.connect(set_theme)
# style(style_key?, active_key?, is_active?)
# -> выбор ключа на основе _is_active + _theme
# -> _button.setStyleSheet(APP_STYLES[key])
# set_theme(theme: str)
# -> _theme = theme -> style() — перерисовка стиля
# clicked / toggled (properties)
# -> делегируют к _button.clicked / _button.toggled
#
# 6) Побочные эффекты:
# - Устанавливает stylesheet на внутреннюю QPushButton.
# - Подключается к глобальному сигналу theme_bus.theme_changed при создании.
#
# 7) Границы ответственности:
# Модуль НЕ управляет layout хоста, НЕ хранит бизнес-логику,
# НЕ регистрирует обработчики кликов (это делает потребитель).
# add_widget() заблокирован — кнопка содержит только QPushButton.
#
# 8) Обработка ошибок:
# add_widget() бросает NotImplementedError при попытке добавить
# дополнительный виджет. set_theme() молча игнорирует невалидные
# значения темы.
#
# 9) Инварианты и контракты:
# - Контейнер всегда содержит ровно одну QPushButton.
# - _theme ∈ {"dark", "light"}.
# - Если _style_key_normal задан, стиль определяется им; иначе —
# используется стандартная пара STANDARD_BUTTON_*_THEME(_ACTIVE).
#
# 10) Правило сопровождения:
# При добавлении новой темы — расширить ветку в style().
# Не добавлять дочерние виджеты внутрь Button.
# Новые делегирующие методы дублировать на _button и super().

View File

@@ -0,0 +1,239 @@
# -*- coding: utf-8 -*-
# gui/components/color_palette.py
"""Палитра выбора цвета — контейнеризованный компонент."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QGridLayout, QPushButton, QSizePolicy, QWidget
from gui.containers.s_container import SContainer
# Стандартная палитра 32 цвета (4×8).
DEFAULT_PALETTE_COLORS: list[str] = [
"#FF6B6B", "#FF8E72", "#FFB26B", "#FFD56B", "#F0E96B", "#B8E86B", "#7EDB79", "#5CC8A8",
"#59B6E6", "#5F9DFF", "#7A84FF", "#A07CFF", "#C27BFF", "#E07ADB", "#F57FB1", "#D97A5C",
"#B35A4A", "#8E4A3D", "#6F5A4D", "#7E7E7E", "#5C5C5C", "#3F4A5A", "#2F6F8F", "#2F8F86",
"#3C8F52", "#6D8F3C", "#8F873C", "#8F6B3C", "#8F4F3C", "#7B3C8F", "#4F3C8F", "#3C5C8F",
]
class ColorPalette(SContainer):
"""Сетка цветных кнопок с единственным выделением.
Сигнал ``color_selected`` испускается при клике.
Публичный API — ``set_color_hex``, ``get_color_hex``, ``set_enabled``.
"""
color_selected = Signal(str) # #RRGGBB
def __init__(
self,
colors: list[str] | None = None,
columns: int = 8,
selected_index: int = 0,
width_percent: int | None = None,
height_percent: int | None = None,
margin: int | tuple[int, int, int, int] = 0,
parent: QWidget | None = None,
):
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
parent=parent,
)
self._colors: list[str] = list(colors or DEFAULT_PALETTE_COLORS)
self._columns = max(1, int(columns))
self._selected_index: int = max(0, min(int(selected_index), len(self._colors) - 1))
self._buttons: list[QPushButton] = []
# Внутренний виджет с QGridLayout — палитра динамическая,
# GridContainer не подходит (ячейки без авторасширения QPushButton).
self._grid_widget = QWidget()
self._grid_layout = QGridLayout(self._grid_widget)
self._grid_layout.setContentsMargins(0, 0, 0, 0)
self._grid_layout.setHorizontalSpacing(4)
self._grid_layout.setVerticalSpacing(4)
super().add_widget(self._grid_widget)
self._build_grid()
# ── Публичный API ─────────────────────────────────────────────
def get_color_hex(self) -> str:
"""Вернуть выбранный цвет ``#RRGGBB``."""
if not self._colors:
return "#7FB3D5"
idx = max(0, min(self._selected_index, len(self._colors) - 1))
return str(self._colors[idx])
def set_color_hex(self, color_hex: str) -> None:
"""Выбрать ближайший цвет палитры к ``color_hex``."""
idx = self._nearest_index(str(color_hex or ""))
if idx == self._selected_index:
return
self._selected_index = idx
self._refresh_selection()
def get_selected_index(self) -> int:
"""Индекс выбранного цвета."""
return self._selected_index
def set_selected_index(self, index: int) -> None:
"""Выбрать цвет по индексу."""
index = max(0, min(int(index), len(self._colors) - 1))
if index == self._selected_index:
return
self._selected_index = index
self._refresh_selection()
def set_enabled(self, enabled: bool) -> None:
"""Включить/выключить все кнопки палитры."""
for button in self._buttons:
button.setEnabled(bool(enabled))
super().set_enabled(enabled)
def add_widget(self, widget, alignment=None):
raise NotImplementedError("ColorPalette is a sealed component")
# ── Внутренние методы ─────────────────────────────────────────
def _build_grid(self) -> None:
self._buttons = []
for idx, color in enumerate(self._colors):
button = QPushButton("")
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
button.clicked.connect(lambda _checked=False, i=idx: self._on_clicked(i))
self._buttons.append(button)
row = idx // self._columns
col = idx % self._columns
self._grid_layout.addWidget(button, row, col)
self._apply_button_style(idx, selected=(idx == self._selected_index))
self._refresh_selection()
def _on_clicked(self, index: int) -> None:
index = max(0, min(int(index), len(self._colors) - 1))
self._selected_index = index
self._refresh_selection()
self.color_selected.emit(self.get_color_hex())
def _refresh_selection(self) -> None:
for idx, button in enumerate(self._buttons):
is_selected = idx == self._selected_index
button.blockSignals(True)
button.setChecked(is_selected)
button.blockSignals(False)
self._apply_button_style(idx, selected=is_selected)
def _apply_button_style(self, index: int, selected: bool) -> None:
if index < 0 or index >= len(self._buttons):
return
color = self._colors[index]
border_color = "#FFFFFF" if selected else "#5F5F5F"
border_width = 2 if selected else 1
self._buttons[index].setStyleSheet(
f"QPushButton {{"
f"background-color: {color};"
f"border: {border_width}px solid {border_color};"
f"border-radius: 2px;"
f"min-height: 18px;"
f"}}"
f"QPushButton:disabled {{"
f"background-color: {color};"
f"border: {border_width}px solid #3A3A3A;"
f"}}"
)
def _nearest_index(self, color_hex: str) -> int:
if not self._colors:
return 0
target = QColor(color_hex)
if not target.isValid():
return 0
best_idx = 0
best_dist: int | None = None
for idx, candidate_hex in enumerate(self._colors):
candidate = QColor(candidate_hex)
dr = int(target.red()) - int(candidate.red())
dg = int(target.green()) - int(candidate.green())
db = int(target.blue()) - int(candidate.blue())
dist = dr * dr + dg * dg + db * db
if best_dist is None or dist < best_dist:
best_dist = dist
best_idx = idx
return best_idx
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Палитра выбора цвета — сетка цветных кнопок (по умолчанию 4×8)
# с единственным выделением и сигналом color_selected, встроенная
# в контейнер SContainer для процентного позиционирования.
#
# 2) Зависимости модуля:
# Импорты: Qt, Signal (PySide6.QtCore),
# QColor (PySide6.QtGui),
# QGridLayout, QPushButton, QSizePolicy, QWidget (PySide6.QtWidgets),
# SContainer (gui.containers.s_container)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Константа DEFAULT_PALETTE_COLORS (list[str], 32 цвета).
# Класс ColorPalette — публичный компонент палитры.
# Сигнал: color_selected(str) — #RRGGBB при клике.
# Методы: get_color_hex(), set_color_hex(), get_selected_index(),
# set_selected_index(), set_enabled().
#
# 4) Состояние (поля):
# _colors: list[str] — массив HEX-цветов палитры
# _columns: int — количество столбцов сетки
# _selected_index: int — индекс текущего выбранного цвета
# _buttons: list[QPushButton]— массив кнопок-ячеек
# _grid_widget: QWidget — внутренний виджет с QGridLayout
# _grid_layout: QGridLayout — компоновка сетки
#
# 5) Последовательность действий и вызовов:
# __init__(colors?, columns, selected_index, ...)
# -> super().__init__(...)
# -> _grid_widget + QGridLayout — контейнер для кнопок
# -> super().add_widget(_grid_widget)
# -> _build_grid() — создание QPushButton для каждого цвета
# -> для каждого цвета: QPushButton -> setCheckable -> connect(_on_clicked)
# -> _apply_button_style(idx, selected)
# -> _refresh_selection()
# set_color_hex(color_hex)
# -> _nearest_index(color_hex) — поиск ближайшего по евклидову расстоянию RGB
# -> _refresh_selection() — обновление всех кнопок
# _on_clicked(index)
# -> _refresh_selection() -> color_selected.emit(get_color_hex())
#
# 6) Побочные эффекты:
# - Устанавливает inline stylesheet на каждую кнопку палитры.
# - Испускает сигнал color_selected при выборе цвета.
#
# 7) Границы ответственности:
# Модуль НЕ подключается к theme_bus (стили инлайновые).
# НЕ хранит «применённый» цвет зоны — только текущий выбор палитры.
# add_widget() заблокирован — компонент запечатан.
#
# 8) Обработка ошибок:
# add_widget() бросает NotImplementedError. _nearest_index() возвращает 0
# при невалидном HEX. Индексы зажимаются в допустимый диапазон (clamp).
#
# 9) Инварианты и контракты:
# - 0 ≤ _selected_index < len(_colors).
# - Ровно одна кнопка в checked-состоянии (exclusive selection).
# - Количество кнопок == len(_colors); _columns ≥ 1.
#
# 10) Правило сопровождения:
# Для изменения набора цветов — менять DEFAULT_PALETTE_COLORS или
# передавать colors в конструктор. Не изменять логику _nearest_index
# без учёта perceptual color distance.

View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
# gui/components/color_swatch.py
"""Цветовой маркер — контейнеризованный компонент."""
from __future__ import annotations
from PySide6.QtGui import QColor, QPainter, QPen, QBrush
from PySide6.QtCore import Qt, QRect
from PySide6.QtWidgets import QWidget, QSizePolicy
from gui.containers.s_container import SContainer
class ColorSwatch(SContainer):
"""Квадрат-маркер цвета зоны.
Контейнеризованный компонент gui-фреймворка.
Размеры задаются через ``width_percent`` / ``height_percent``,
отступы — через ``margin``.
"""
def __init__(
self,
color: str = "#9E9E9EC0",
width_percent: int | None = None,
height_percent: int | None = None,
margin: int | tuple[int, int, int, int] = 0,
parent: QWidget | None = None,
style: str | None = None,
active_style: str | None = None,
is_active: bool | None = None,
):
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
parent=parent,
style=style,
active_style=active_style,
is_active=is_active,
)
self._swatch = _SwatchCanvas(color, parent=self)
self._swatch.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Expanding,
)
self.add_widget(self._swatch)
# ── Публичный API ─────────────────────────────────────────────
def set_color(self, color: str) -> None:
"""Установить цвет маркера (hex ``#RRGGBB`` или ``#RRGGBBAA``)."""
self._swatch.set_color(color)
def get_color(self) -> str:
"""Вернуть текущий цвет маркера."""
return self._swatch.get_color()
class _SwatchCanvas(QWidget):
"""Внутренний виджет, рисующий заливку + тонкую рамку."""
_BORDER_COLOR = QColor("#555555")
_BORDER_RADIUS = 3
def __init__(self, color: str, parent: QWidget | None = None):
super().__init__(parent)
self._qcolor = QColor(color)
self._hex = color
def set_color(self, color: str) -> None:
self._hex = color
self._qcolor = QColor(color)
self.update()
def get_color(self) -> str:
return self._hex
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.rect().adjusted(1, 1, -1, -1)
painter.setPen(QPen(self._BORDER_COLOR, 1))
painter.setBrush(QBrush(self._qcolor))
painter.drawRoundedRect(rect, self._BORDER_RADIUS, self._BORDER_RADIUS)
painter.end()
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Цветовой маркер (квадрат) для визуальной индикации цвета зоны,
# реализованный как SContainer с внутренним виджетом _SwatchCanvas,
# который рисует заливку и рамку через QPainter.
#
# 2) Зависимости модуля:
# Импорты: QColor, QPainter, QPen, QBrush (PySide6.QtGui),
# Qt, QRect (PySide6.QtCore),
# QWidget, QSizePolicy (PySide6.QtWidgets),
# SContainer (gui.containers.s_container)
# Хост-класс / базовый класс: ColorSwatch -> SContainer;
# _SwatchCanvas -> QWidget
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс ColorSwatch — публичный компонент маркера цвета.
# Методы: set_color(str), get_color() -> str.
# Класс _SwatchCanvas — внутренний (непубличный).
#
# 4) Состояние (поля):
# ColorSwatch:
# _swatch: _SwatchCanvas — внутренний виджет рисования
# _SwatchCanvas:
# _qcolor: QColor — текущий цвет для заливки
# _hex: str — текущий HEX-код цвета
# _BORDER_COLOR: QColor — цвет рамки (константа #555555)
# _BORDER_RADIUS: int — радиус скругления (константа 3)
#
# 5) Последовательность действий и вызовов:
# __init__(color, ...)
# -> super().__init__(...) — SContainer
# -> _SwatchCanvas(color) -> setSizePolicy(Expanding)
# -> self.add_widget(_swatch)
# set_color(color)
# -> _swatch.set_color(color) -> QColor(color) -> update() [перерисовка]
# _SwatchCanvas.paintEvent(event)
# -> QPainter -> drawRoundedRect с заливкой _qcolor и рамкой _BORDER_COLOR
#
# 6) Побочные эффекты:
# - Перерисовывает виджет при изменении цвета (update()).
# - Никаких сигналов не испускает.
#
# 7) Границы ответственности:
# Модуль только отображает цвет. НЕ взаимодействует с theme_bus.
# НЕ обрабатывает клики. НЕ связан с палитрой или выбором цвета.
#
# 8) Обработка ошибок:
# Невалидный HEX приводит к QColor.isValid() == False, что Qt обрабатывает
# как прозрачный/чёрный. Явных проверок нет.
#
# 9) Инварианты и контракты:
# - _SwatchCanvas.paintEvent всегда рисует прямоугольник с рамкой.
# - set_color / get_color симметричны по HEX-строке.
#
# 10) Правило сопровождения:
# При необходимости нового формата цвета (HSL, RGB-кортеж) — расширять
# set_color() с конвертацией. Не менять paintEvent без проверки antialiasing.

View File

@@ -0,0 +1,281 @@
# -*- coding: utf-8 -*-
# gui/components/combo_box.py
"""Обёртка над QComboBox с централизованными стилями."""
from PySide6.QtWidgets import QComboBox, QSizePolicy
from PySide6.QtCore import Slot
from gui.styles import APP_STYLES
from gui.theme_bus import theme_bus
from gui.containers.s_container import SContainer
class ComboBox(SContainer):
"""Кастомный QComboBox с темизацией и стилями APP_STYLES."""
def __init__(
self,
width_percent: int | None = None,
height_percent: int | None = None,
margin: int | tuple[int, int, int, int] = 0,
style: str = "FORM_WIDGET",
active_style: str | None = None,
is_active: bool | None = None,
content_fit: bool = True,
parent=None,
):
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
style=style,
active_style=active_style,
is_active=is_active,
content_fit=content_fit,
parent=parent,
)
self._theme = "dark"
self._is_active = False
self._base_style_key = style
self._style_key_normal = None
self._style_key_active = None
self._combo = QComboBox()
self._combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
super().add_widget(self._combo)
if active_style is not None:
self._style_key_normal = style
self._style_key_active = active_style
if is_active is not None:
self._is_active = bool(is_active)
self.style()
theme_bus.theme_changed.connect(self.set_theme)
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
) -> None:
"""Короткий метод применения стиля. Можно задать ключи и активность явно."""
if style_key is not None:
self._base_style_key = style_key
if active_key is not None:
self._style_key_normal = style_key
self._style_key_active = active_key
else:
self._style_key_normal = None
self._style_key_active = None
if is_active is not None:
self._is_active = bool(is_active)
if self._style_key_normal is not None:
active_key = self._style_key_active or self._style_key_normal
key = active_key if self._is_active else self._style_key_normal
themed = f"{key}_{self._theme.upper()}"
if themed in APP_STYLES:
key = themed
self._combo.setStyleSheet(APP_STYLES.get(key, ""))
return
base_key = self._base_style_key
key = base_key
if self._theme == "light":
if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES:
key = f"{base_key}_LIGHT_ACTIVE"
elif f"{base_key}_LIGHT" in APP_STYLES:
key = f"{base_key}_LIGHT"
else:
if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES:
key = f"{base_key}_DARK_ACTIVE"
elif f"{base_key}_DARK" in APP_STYLES:
key = f"{base_key}_DARK"
self._combo.setStyleSheet(APP_STYLES.get(key, ""))
@Slot(str)
def set_theme(self, theme: str) -> None:
"""Внешний слот: принимает 'dark' или 'light'."""
theme = (theme or "").strip().lower()
if theme not in ("dark", "light"):
return
if self._theme == theme:
return
self._theme = theme
self.style()
def set_items(self, items: list[str]) -> None:
"""Заменить список элементов."""
self._combo.clear()
self._combo.addItems(items)
def set_editable(self, editable: bool) -> None:
self._combo.setEditable(editable)
def set_enabled(self, enabled: bool) -> None:
"""Управление доступностью."""
self._combo.setEnabled(enabled)
super().setEnabled(enabled)
def set_min_width(self, width: int) -> None:
"""Минимальная ширина."""
self._combo.setMinimumWidth(width)
super().setMinimumWidth(width)
def set_min_height(self, height: int) -> None:
"""Минимальная высота."""
self._combo.setMinimumHeight(height)
super().setMinimumHeight(height)
def set_max_width(self, width: int) -> None:
"""Максимальная ширина."""
self._combo.setMaximumWidth(width)
super().setMaximumWidth(width)
def set_max_height(self, height: int) -> None:
"""Максимальная высота."""
self._combo.setMaximumHeight(height)
super().setMaximumHeight(height)
def set_fixed_size(self, width: int, height: int) -> None:
"""Фиксированный размер."""
self._combo.setMinimumSize(width, height)
self._combo.setMaximumSize(width, height)
super().setMinimumSize(width, height)
super().setMaximumSize(width, height)
def set_index(self, index: int) -> None:
"""Установить текущий индекс."""
self._combo.setCurrentIndex(index)
def set_current_text(self, text: str) -> None:
self._combo.setCurrentText(text)
def set_placeholder_text(self, text: str) -> None:
"""Установить текст-заполнитель (если поддерживается Qt)."""
line_edit = self._combo.lineEdit()
if line_edit is not None:
line_edit.setPlaceholderText(text)
if hasattr(self._combo, "setPlaceholderText"):
self._combo.setPlaceholderText(text)
def get_index(self) -> int:
"""Получить текущий индекс."""
return self._combo.currentIndex()
def get_current_text(self) -> str:
return self._combo.currentText()
def set_tooltip(self, text: str) -> None:
"""Подсказка."""
self._combo.setToolTip(text)
def set_size_policy(self, horizontal, vertical) -> None:
"""Политика размеров."""
self._combo.setSizePolicy(horizontal, vertical)
super().setSizePolicy(horizontal, vertical)
@property
def current_index_changed(self):
return self._combo.currentIndexChanged
@property
def current_text_changed(self):
return self._combo.currentTextChanged
@property
def text_edited(self):
line_edit = self._combo.lineEdit()
if line_edit is None:
raise AttributeError("text_edited is unavailable for non-editable ComboBox")
return line_edit.textEdited
def set_item_enabled(self, index: int, enabled: bool) -> None:
"""Включить/выключить элемент списка по индексу."""
model = self._combo.model()
if model is None:
return
item = model.item(index) if hasattr(model, "item") else None
if item is not None and hasattr(item, "setEnabled"):
item.setEnabled(bool(enabled))
def show_popup(self) -> None:
self._combo.showPopup()
def add_widget(self, widget, alignment=None):
raise NotImplementedError("ComboBox can contain only one QComboBox")
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Обёртка над QComboBox, встроенная в SContainer, с поддержкой
# централизованных стилей APP_STYLES и автоматическим
# переключением тем (dark/light) через theme_bus.
#
# 2) Зависимости модуля:
# Импорты: QComboBox, QSizePolicy (PySide6.QtWidgets),
# Slot (PySide6.QtCore),
# APP_STYLES (gui.styles),
# theme_bus (gui.theme_bus),
# SContainer (gui.containers.s_container)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс ComboBox — публичный виджет выпадающего списка.
# Методы: style(), set_theme(), set_items(), set_index(), get_index(),
# set_placeholder_text(), set_enabled(), set_item_enabled(),
# set_min/max_width/height(), set_fixed_size(), set_tooltip(),
# set_size_policy().
# Свойство: current_index_changed — сигнал currentIndexChanged.
#
# 4) Состояние (поля):
# _theme: str — текущая тема ("dark" | "light")
# _is_active: bool — признак активного состояния
# _base_style_key: str — базовый ключ стиля (по умолчанию "FORM_WIDGET")
# _style_key_normal: str|None — явный ключ нормального стиля
# _style_key_active: str|None — явный ключ активного стиля
# _combo: QComboBox — внутренний виджет
#
# 5) Последовательность действий и вызовов:
# __init__(style="FORM_WIDGET", ...)
# -> super().__init__(...)
# -> QComboBox() -> setSizePolicy -> super().add_widget(_combo)
# -> style() — первичное применение
# -> theme_bus.theme_changed.connect(set_theme)
# style(style_key?, active_key?, is_active?)
# -> определяет ключ через комбинацию base_key + тема + active
# -> _combo.setStyleSheet(APP_STYLES[key])
# set_items(items)
# -> _combo.clear() -> _combo.addItems(items)
#
# 6) Побочные эффекты:
# - Устанавливает stylesheet на внутренний QComboBox.
# - Подключается к theme_bus.theme_changed при создании.
#
# 7) Границы ответственности:
# Модуль НЕ хранит бизнес-данные выбранного элемента.
# НЕ валидирует содержимое списка.
# add_widget() заблокирован — компонент запечатан.
#
# 8) Обработка ошибок:
# add_widget() бросает NotImplementedError.
# set_theme() молча игнорирует невалидные значения.
# set_item_enabled() безопасно пропускает отсутствующий model/item.
#
# 9) Инварианты и контракты:
# - Контейнер содержит ровно один QComboBox.
# - _theme ∈ {"dark", "light"}.
# - Стиль разрешается по цепочке: явный ключ → base_key + суффикс темы.
#
# 10) Правило сопровождения:
# Новые стили — добавлять в APP_STYLES с суффиксами _DARK/_LIGHT/_DARK_ACTIVE/_LIGHT_ACTIVE.
# Делегирующие методы дублировать на _combo и super().

View File

@@ -0,0 +1,248 @@
# -*- coding: utf-8 -*-
# gui/components/coordinate_input.py
"""Виджет для ввода координат"""
from PySide6.QtWidgets import QDoubleSpinBox, QSizePolicy
from PySide6.QtCore import Qt, Slot
from gui.containers.s_container import SContainer
from gui.styles import APP_STYLES
from gui.theme_bus import theme_bus
from error_logger import log_exception
class CoordinateInput(SContainer):
"""Виджет для ввода координат с валидацией"""
def __init__(
self,
min_value: float = 0.0,
max_value: float = 100000.0,
decimals: int = 6,
step: float = 0.000001,
min_width: int = 150,
alignment: Qt.Alignment = Qt.AlignCenter,
parent=None,
style: str = "COORDINATE_INPUT",
active_style: str | None = None,
is_active: bool | None = None,
):
super().__init__(
width_percent=None,
height_percent=None,
margin=0,
style=style,
active_style=active_style,
is_active=is_active,
parent=parent,
)
self._theme = "dark"
self._is_active = False
self._base_style_key = style
self._style_key_normal = None
self._style_key_active = None
self._input = QDoubleSpinBox()
self._input.setRange(min_value, max_value)
self._input.setDecimals(decimals)
self._input.setSingleStep(step)
self._input.setMinimumWidth(min_width)
self._input.setAlignment(alignment)
self._input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
super().add_widget(self._input)
if active_style is not None:
self._style_key_normal = style
self._style_key_active = active_style
if is_active is not None:
self._is_active = bool(is_active)
self.style()
theme_bus.theme_changed.connect(self.set_theme)
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
) -> None:
"""Короткий метод применения стиля. Можно задать ключи и активность явно."""
if style_key is not None:
self._base_style_key = style_key
if active_key is not None:
self._style_key_normal = style_key
self._style_key_active = active_key
else:
self._style_key_normal = None
self._style_key_active = None
if is_active is not None:
self._is_active = bool(is_active)
if self._style_key_normal is not None:
active_key = self._style_key_active or self._style_key_normal
key = active_key if self._is_active else self._style_key_normal
themed = f"{key}_{self._theme.upper()}"
if themed in APP_STYLES:
key = themed
self._input.setStyleSheet(APP_STYLES.get(key, ""))
return
base_key = self._base_style_key
key = base_key
if self._theme == "light":
if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES:
key = f"{base_key}_LIGHT_ACTIVE"
elif f"{base_key}_LIGHT" in APP_STYLES:
key = f"{base_key}_LIGHT"
else:
if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES:
key = f"{base_key}_DARK_ACTIVE"
elif f"{base_key}_DARK" in APP_STYLES:
key = f"{base_key}_DARK"
self._input.setStyleSheet(APP_STYLES.get(key, ""))
@Slot(str)
def set_theme(self, theme: str) -> None:
"""Внешний слот: принимает 'dark' или 'light'."""
theme = (theme or "").strip().lower()
if theme not in ("dark", "light"):
return
if self._theme == theme:
return
self._theme = theme
self.style()
def set_prefix(self, prefix: str):
"""Установка префикса"""
self._input.setPrefix(prefix)
def set_range(self, min_val, max_val):
"""Установка диапазона"""
self._input.setRange(min_val, max_val)
def set_decimals(self, decimals: int):
"""Установка количества десятичных знаков"""
self._input.setDecimals(decimals)
def set_step(self, step: float) -> None:
"""Установка шага"""
self._input.setSingleStep(step)
def set_value(self, value):
"""Безопасная установка значения"""
try:
self._input.setValue(float(value))
except (ValueError, TypeError) as _exc:
log_exception(__name__, "set_value", _exc)
def get_value(self):
return self._input.value()
@property
def valueChanged(self):
"""Предоставить сигнал valueChanged из внутреннего QDoubleSpinBox."""
return self._input.valueChanged
def set_enabled(self, enabled: bool) -> None:
self._input.setEnabled(enabled)
super().setEnabled(enabled)
def set_min_width(self, width: int) -> None:
self._input.setMinimumWidth(width)
def set_min_height(self, height: int) -> None:
self._input.setMinimumHeight(height)
def set_max_width(self, width: int) -> None:
self._input.setMaximumWidth(width)
def set_max_height(self, height: int) -> None:
self._input.setMaximumHeight(height)
def set_fixed_size(self, width: int, height: int) -> None:
self._input.setMinimumSize(width, height)
self._input.setMaximumSize(width, height)
def set_tooltip(self, text: str) -> None:
self._input.setToolTip(text)
def set_size_policy(self, horizontal, vertical) -> None:
self._input.setSizePolicy(horizontal, vertical)
super().setSizePolicy(horizontal, vertical)
def add_widget(self, widget, alignment=None):
raise NotImplementedError("CoordinateInput can contain only one QDoubleSpinBox")
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Виджет для ввода координат (широта, долгота и т.д.) на базе
# QDoubleSpinBox внутри SContainer, с поддержкой валидации
# диапазона, настраиваемой точностью и стилями APP_STYLES.
#
# 2) Зависимости модуля:
# Импорты: QDoubleSpinBox, QSizePolicy (PySide6.QtWidgets),
# Qt, Slot (PySide6.QtCore),
# SContainer (gui.containers.s_container),
# APP_STYLES (gui.styles),
# theme_bus (gui.theme_bus)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс CoordinateInput — публичный виджет ввода координат.
# Методы: style(), set_theme(), set_prefix(), set_range(),
# set_decimals(), set_step(), set_value(), get_value(),
# set_enabled(), set_min/max_width/height(), set_fixed_size(),
# set_tooltip(), set_size_policy().
# Свойство: valueChanged — сигнал QDoubleSpinBox.valueChanged.
#
# 4) Состояние (поля):
# _theme: str — текущая тема
# _is_active: bool — признак активного состояния
# _base_style_key: str — базовый ключ стиля ("COORDINATE_INPUT")
# _style_key_normal: str|None — явный ключ нормального стиля
# _style_key_active: str|None — явный ключ активного стиля
# _input: QDoubleSpinBox — внутренний виджет ввода
#
# 5) Последовательность действий и вызовов:
# __init__(min_value, max_value, decimals, step, min_width, alignment, ...)
# -> super().__init__(...)
# -> QDoubleSpinBox() с setRange, setDecimals, setSingleStep, setMinimumWidth
# -> super().add_widget(_input)
# -> style() -> theme_bus.theme_changed.connect(set_theme)
# set_value(value)
# -> try float(value) -> _input.setValue()
# -> except: pass (тихое игнорирование)
# valueChanged (property)
# -> делегирует к _input.valueChanged
#
# 6) Побочные эффекты:
# - Устанавливает stylesheet на QDoubleSpinBox.
# - Подключается к theme_bus.theme_changed.
#
# 7) Границы ответственности:
# Модуль НЕ интерпретирует значения координат семантически.
# НЕ выполняет геокодирование. add_widget() заблокирован.
#
# 8) Обработка ошибок:
# set_value() глотает ValueError/TypeError при некорректном вводе.
# add_widget() бросает NotImplementedError.
# set_theme() молча игнорирует невалидные темы.
#
# 9) Инварианты и контракты:
# - Контейнер содержит ровно один QDoubleSpinBox.
# - Значение всегда в пределах [min_value, max_value].
# - decimals определяет точность отображения.
#
# 10) Правило сопровождения:
# При добавлении суффикса/префикса — использовать set_prefix().
# Стили — через APP_STYLES с ключом COORDINATE_INPUT + суффиксы.

View File

@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
# gui/components/dialog.py
"""Каноническая dialog-обёртка проекта."""
from __future__ import annotations
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QDialog, QVBoxLayout
from gui.containers import VContainer
from gui.containers._widget_style_service import WidgetStyleService
from gui.theme_bus import theme_bus
class Dialog(QDialog):
"""Базовая stylable-обёртка над QDialog с корневым VContainer."""
def __init__(
self,
title: str = "",
width: int | None = None,
height: int | None = None,
modal: bool = True,
content_margin: int | tuple[int, int, int, int] = 0,
content_spacing: int = 0,
style: str = "DIALOG",
parent=None,
):
super().__init__(parent)
# Зависимости
self._style_service = WidgetStyleService(self)
# Ссылки на ключевые виджеты
self._content_container: VContainer | None = None
# Визуальная настройка
self.setModal(bool(modal))
if title:
self.setWindowTitle(title)
if width is not None and height is not None:
self.resize(width, height)
# Сборка и подключение
self._build_root(content_margin, content_spacing)
self._connect_base_signals()
# Первичная синхронизация стиля
self.style(style)
# ── Сборка интерфейса ────────────────────────────────────────
def _build_root(
self,
content_margin: int | tuple[int, int, int, int],
content_spacing: int,
) -> None:
self._content_container = VContainer(
margin=content_margin,
spacing=content_spacing,
)
root_layout = QVBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
root_layout.addWidget(self._content_container)
# ── Подключение сигналов ─────────────────────────────────────
def _connect_base_signals(self) -> None:
theme_bus.theme_changed.connect(self.set_theme)
# ── Публичный API ────────────────────────────────────────────
@property
def content_container(self) -> VContainer:
return self._content_container
def add_widget(self, widget) -> None:
self._content_container.add_widget(widget)
def add_stretch(self, stretch: int = 1) -> None:
self._content_container.add_stretch(stretch)
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
) -> None:
self._style_service.apply(
style_key=style_key,
active_key=active_key,
is_active=is_active,
)
# ── Обработчики событий ──────────────────────────────────────
@Slot(str)
def set_theme(self, theme: str) -> None:
self._style_service.handle_theme_changed(theme)

View File

@@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
# gui/components/double_spin_box.py
"""Обёртка над QDoubleSpinBox с поддержкой централизованных APP_STYLES."""
from PySide6.QtWidgets import QDoubleSpinBox, QSizePolicy
from PySide6.QtCore import Qt, Slot, Signal
from gui.containers.s_container import SContainer
from gui.styles import APP_STYLES
from gui.theme_bus import theme_bus
from error_logger import log_exception
class DoubleSpinBox(SContainer):
"""Кастомный QDoubleSpinBox с переключением стилей по теме."""
stepped = Signal()
class _StepAwareSpinBox(QDoubleSpinBox):
def __init__(self, on_step, parent=None):
super().__init__(parent)
self._on_step = on_step
def stepBy(self, steps: int) -> None: # noqa: N802 (Qt API)
super().stepBy(steps)
if steps and callable(self._on_step):
self._on_step()
def __init__(
self,
min_value: float = 0.0,
max_value: float = 100000.0,
decimals: int = 0,
step: float = 1.0,
suffix: str = " мм",
keyboard_tracking: bool = True,
width_percent: int | None = None,
height_percent: int | None = None,
margin: int | tuple[int, int, int, int] = 0,
style: str = "COORDINATE_INPUT",
active_style: str | None = None,
is_active: bool | None = None,
parent=None,
):
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
style=style,
active_style=active_style,
is_active=is_active,
parent=parent,
)
self._theme = "dark"
self._is_active = False
self._base_style_key = style
self._style_key_normal = None
self._style_key_active = None
self._input = self._StepAwareSpinBox(self._emit_stepped)
self._input.setRange(min_value, max_value)
self._input.setDecimals(decimals)
self._input.setSingleStep(step)
self._input.setSuffix(suffix)
self._input.setAlignment(Qt.AlignCenter)
self._input.setKeyboardTracking(keyboard_tracking)
self._input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
super().add_widget(self._input)
if active_style is not None:
self._style_key_normal = style
self._style_key_active = active_style
if is_active is not None:
self._is_active = bool(is_active)
self.style()
theme_bus.theme_changed.connect(self.set_theme)
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
) -> None:
"""Применить стиль по базовым/активным ключам с учётом текущей темы."""
if style_key is not None:
self._base_style_key = style_key
if active_key is not None:
self._style_key_normal = style_key
self._style_key_active = active_key
else:
self._style_key_normal = None
self._style_key_active = None
if is_active is not None:
self._is_active = bool(is_active)
if self._style_key_normal is not None:
active_key = self._style_key_active or self._style_key_normal
key = active_key if self._is_active else self._style_key_normal
themed = f"{key}_{self._theme.upper()}"
if themed in APP_STYLES:
key = themed
self._input.setStyleSheet(APP_STYLES.get(key, ""))
return
base_key = self._base_style_key
key = base_key
if self._theme == "light":
if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES:
key = f"{base_key}_LIGHT_ACTIVE"
elif f"{base_key}_LIGHT" in APP_STYLES:
key = f"{base_key}_LIGHT"
else:
if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES:
key = f"{base_key}_DARK_ACTIVE"
elif f"{base_key}_DARK" in APP_STYLES:
key = f"{base_key}_DARK"
self._input.setStyleSheet(APP_STYLES.get(key, ""))
@Slot(str)
def set_theme(self, theme: str) -> None:
theme = (theme or "").strip().lower()
if theme not in ("dark", "light"):
return
if self._theme == theme:
return
self._theme = theme
self.style()
def set_value(self, value: float) -> None:
self._input.setValue(value)
def get_value(self) -> float:
return self._input.value()
def get_min_value(self) -> float:
return float(self._input.minimum())
def get_max_value(self) -> float:
return float(self._input.maximum())
@property
def valueChanged(self):
return self._input.valueChanged
@property
def editing_finished(self):
return self._input.editingFinished
@property
def return_pressed(self):
line_edit = self._input.lineEdit()
if line_edit is None:
return self._input.editingFinished
return line_edit.returnPressed
def set_enabled(self, enabled: bool) -> None:
self._input.setEnabled(enabled)
super().setEnabled(enabled)
def set_min_width(self, width: int) -> None:
self._input.setMinimumWidth(width)
super().setMinimumWidth(width)
def set_min_height(self, height: int) -> None:
self._input.setMinimumHeight(height)
super().setMinimumHeight(height)
def set_max_width(self, width: int) -> None:
self._input.setMaximumWidth(width)
super().setMaximumWidth(width)
def set_max_height(self, height: int) -> None:
self._input.setMaximumHeight(height)
super().setMaximumHeight(height)
def set_fixed_size(self, width: int, height: int) -> None:
self._input.setMinimumSize(width, height)
self._input.setMaximumSize(width, height)
super().setMinimumSize(width, height)
super().setMaximumSize(width, height)
def set_tooltip(self, text: str) -> None:
self._input.setToolTip(text)
def set_size_policy(self, horizontal, vertical) -> None:
self._input.setSizePolicy(horizontal, vertical)
super().setSizePolicy(horizontal, vertical)
def set_range(self, min_value: float, max_value: float) -> None:
self._input.setRange(min_value, max_value)
def set_step(self, step: float) -> None:
self._input.setSingleStep(step)
def commit_pending_input(self) -> None:
"""Принудительно применить текст из редактора spinbox к текущему value."""
try:
self._input.interpretText()
except Exception as e:
log_exception(__name__, "commit_pending_input", e)
def _emit_stepped(self) -> None:
self.stepped.emit()
def add_widget(self, widget, alignment=None):
raise NotImplementedError("DoubleSpinBox can contain only one QDoubleSpinBox")
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Обёртка над QDoubleSpinBox в SContainer для ввода числовых значений
# (размеры в мм и т.п.) с суффиксом, шагом, поддержкой APP_STYLES и
# автоматической сменой темы.
#
# 2) Зависимости модуля:
# Импорты: QDoubleSpinBox, QSizePolicy (PySide6.QtWidgets),
# Qt, Slot (PySide6.QtCore),
# SContainer (gui.containers.s_container),
# APP_STYLES (gui.styles),
# theme_bus (gui.theme_bus)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс DoubleSpinBox — публичный виджет числового ввода.
# Методы: style(), set_theme(), set_value(), get_value(),
# get_min_value(), get_max_value(), set_range(), set_step(),
# set_enabled(), set_min/max_width/height(), set_fixed_size(),
# set_tooltip(), set_size_policy().
# Свойства: valueChanged, editing_finished.
#
# 4) Состояние (поля):
# _theme: str — текущая тема
# _is_active: bool — признак активного состояния
# _base_style_key: str — базовый ключ стиля ("COORDINATE_INPUT")
# _style_key_normal: str|None — явный нормальный стиль
# _style_key_active: str|None — явный активный стиль
# _input: QDoubleSpinBox — внутренний виджет
#
# 5) Последовательность действий и вызовов:
# __init__(min_value, max_value, decimals, step, suffix, keyboard_tracking, ...)
# -> super().__init__(...)
# -> QDoubleSpinBox() с setRange, setDecimals, setSingleStep,
# setSuffix, setAlignment(Center), setKeyboardTracking
# -> super().add_widget(_input)
# -> style() -> theme_bus.theme_changed.connect(set_theme)
# style(style_key?, active_key?, is_active?)
# -> разрешение ключа: явный → base_key + _DARK/_LIGHT + _ACTIVE
# -> _input.setStyleSheet(APP_STYLES[key])
#
# 6) Побочные эффекты:
# - Устанавливает stylesheet на QDoubleSpinBox.
# - Подключается к theme_bus.theme_changed.
#
# 7) Границы ответственности:
# Модуль НЕ валидирует единицы измерения. НЕ конвертирует значения.
# add_widget() заблокирован.
#
# 8) Обработка ошибок:
# add_widget() бросает NotImplementedError. set_theme() игнорирует
# невалидные значения.
#
# 9) Инварианты и контракты:
# - Контейнер содержит ровно один QDoubleSpinBox.
# - Значение в пределах [min_value, max_value].
# - keyboard_tracking определяет, испускается ли valueChanged при каждом
# нажатии клавиши или только по завершении ввода.
#
# 10) Правило сопровождения:
# Отличие от CoordinateInput: суффикс, keyboard_tracking,
# decimals=0 по умолчанию. Не дублировать этот класс для целочисленного ввода —
# достаточно decimals=0.

View File

@@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
# gui/components/group_box.py
"""Обёртка над QGroupBox с контейнером SContainer."""
from __future__ import annotations
from PySide6.QtCore import Slot, Qt
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QWidget, QSizePolicy
from gui.containers.s_container import SContainer
from gui.styles import APP_STYLES
from gui.theme_bus import theme_bus
class GroupBox(SContainer):
"""QGroupBox с централизованным стилем и внутренним layout по умолчанию."""
_ALIGN_MAP = {
"top": Qt.AlignmentFlag.AlignTop,
"bottom": Qt.AlignmentFlag.AlignBottom,
"left": Qt.AlignmentFlag.AlignLeft,
"right": Qt.AlignmentFlag.AlignRight,
"hcenter": Qt.AlignmentFlag.AlignHCenter,
"vcenter": Qt.AlignmentFlag.AlignVCenter,
"center": Qt.AlignmentFlag.AlignCenter,
}
def __init__(
self,
title: str = "",
width_percent: int | None = None,
height_percent: int | None = None,
margin: int | tuple[int, int, int, int] = 0,
content_margins: int | tuple[int, int, int, int] = 10,
spacing: int = 8,
alignment=None,
style: str = "GROUP_BOX",
active_style: str | None = None,
is_active: bool | None = None,
content_fit: bool = True,
parent=None,
):
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
margin=margin,
style=style,
active_style=active_style,
is_active=is_active,
content_fit=content_fit,
parent=parent,
)
self._theme = "dark"
self._is_active = False
self._base_style_key = style
self._style_key_normal = None
self._style_key_active = None
self._group_box = QGroupBox(title)
self._group_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
self._group_layout = QVBoxLayout()
if isinstance(content_margins, (list, tuple)) and len(content_margins) == 4:
self._group_layout.setContentsMargins(*content_margins)
else:
self._group_layout.setContentsMargins(content_margins, content_margins, content_margins, content_margins)
self._group_layout.setSpacing(spacing)
if alignment is not None:
self._group_layout.setAlignment(self._normalize_alignment(alignment))
self._group_box.setLayout(self._group_layout)
super().add_widget(self._group_box)
if active_style is not None:
self._style_key_normal = style
self._style_key_active = active_style
if is_active is not None:
self._is_active = bool(is_active)
self.style()
theme_bus.theme_changed.connect(self.set_theme)
def style(
self,
style_key: str | None = None,
active_key: str | None = None,
is_active: bool | None = None,
) -> None:
"""Короткий метод применения стиля. Можно задать ключи и активность явно."""
if style_key is not None:
self._base_style_key = style_key
if active_key is not None:
self._style_key_normal = style_key
self._style_key_active = active_key
else:
self._style_key_normal = None
self._style_key_active = None
if is_active is not None:
self._is_active = bool(is_active)
if self._style_key_normal is not None:
active_key = self._style_key_active or self._style_key_normal
key = active_key if self._is_active else self._style_key_normal
themed = f"{key}_{self._theme.upper()}"
if themed in APP_STYLES:
key = themed
self._group_box.setStyleSheet(APP_STYLES.get(key, ""))
return
base_key = self._base_style_key
key = base_key
if self._theme == "light":
if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES:
key = f"{base_key}_LIGHT_ACTIVE"
elif f"{base_key}_LIGHT" in APP_STYLES:
key = f"{base_key}_LIGHT"
else:
if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES:
key = f"{base_key}_DARK_ACTIVE"
elif f"{base_key}_DARK" in APP_STYLES:
key = f"{base_key}_DARK"
self._group_box.setStyleSheet(APP_STYLES.get(key, ""))
@Slot(str)
def set_theme(self, theme: str) -> None:
"""Внешний слот: принимает 'dark' или 'light'."""
theme = (theme or "").strip().lower()
if theme not in ("dark", "light"):
return
if self._theme == theme:
return
self._theme = theme
self.style()
def set_title(self, title: str) -> None:
self._group_box.setTitle(title)
def get_title(self) -> str:
return self._group_box.title()
def add_widget(self, widget: QWidget) -> None:
self._group_layout.addWidget(widget)
def add_stretch(self, stretch: int = 1) -> None:
self._group_layout.addStretch(stretch)
def set_margins(self, margin: int | tuple[int, int, int, int]) -> None:
if isinstance(margin, (list, tuple)) and len(margin) == 4:
self._group_layout.setContentsMargins(*margin)
else:
self._group_layout.setContentsMargins(margin, margin, margin, margin)
def set_spacing(self, spacing: int) -> None:
self._group_layout.setSpacing(spacing)
def _normalize_alignment(self, alignment: str | Qt.Alignment) -> Qt.Alignment:
"""Преобразует строковое значение alignment в Qt.Alignment."""
if isinstance(alignment, str):
key = alignment.strip().lower()
mapped = self._ALIGN_MAP.get(key)
if mapped is None:
raise ValueError(f"Unknown alignment '{key}'. Allowed: {list(self._ALIGN_MAP.keys())}")
return mapped
return alignment
def set_alignment(self, alignment: str | Qt.Alignment) -> None:
"""Устанавливает выравнивание layout (строка или Qt.Alignment)."""
self._group_layout.setAlignment(self._normalize_alignment(alignment))
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Обёртка над QGroupBox, встроенная в SContainer, с заголовком,
# внутренним QVBoxLayout для дочерних виджетов и поддержкой стилей
# APP_STYLES + theme_bus.
#
# 2) Зависимости модуля:
# Импорты: Slot, Qt (PySide6.QtCore),
# QGroupBox, QVBoxLayout, QWidget, QSizePolicy (PySide6.QtWidgets),
# SContainer (gui.containers.s_container),
# APP_STYLES (gui.styles),
# theme_bus (gui.theme_bus)
# Хост-класс / базовый класс: SContainer
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс GroupBox — публичный контейнерный виджет с заголовком.
# Методы: style(), set_theme(), set_title(), get_title(),
# add_widget(QWidget), add_stretch(), set_margins(),
# set_spacing(), set_alignment().
#
# 4) Состояние (поля):
# _theme: str — текущая тема
# _is_active: bool — признак активного состояния
# _base_style_key: str — базовый ключ стиля ("GROUP_BOX")
# _style_key_normal: str|None — явный нормальный стиль
# _style_key_active: str|None — явный активный стиль
# _group_box: QGroupBox — внутренний QGroupBox
# _group_layout: QVBoxLayout — layout внутри QGroupBox
# _ALIGN_MAP: dict — маппинг строковых выравниваний в Qt.Alignment
#
# 5) Последовательность действий и вызовов:
# __init__(title, content_margins, spacing, alignment, ...)
# -> super().__init__(...)
# -> QGroupBox(title) -> QVBoxLayout -> setContentsMargins, setSpacing
# -> setAlignment(если передано)
# -> _group_box.setLayout(_group_layout)
# -> super().add_widget(_group_box) — добавление QGroupBox в SContainer
# -> style() -> theme_bus.theme_changed.connect(set_theme)
# add_widget(widget) — ПЕРЕОПРЕДЕЛЁН:
# -> _group_layout.addWidget(widget) (добавляет внутрь QGroupBox, не в SContainer)
#
# 6) Побочные эффекты:
# - Устанавливает stylesheet на QGroupBox.
# - Подключается к theme_bus.theme_changed.
# - add_widget() изменяет _group_layout (не SContainer layout).
#
# 7) Границы ответственности:
# Модуль — визуальная группировка. НЕ реализует вложенные стили
# для дочерних элементов. НЕ ограничивает типы дочерних виджетов.
#
# 8) Обработка ошибок:
# _normalize_alignment() бросает ValueError при невалидной строке
# выравнивания.
#
# 9) Инварианты и контракты:
# - _group_box всегда имеет QVBoxLayout.
# - Допустимые строки выравнивания: top, bottom, left, right,
# hcenter, vcenter, center.
# - add_widget() GroupBox — НЕ запечатан, принимает любые QWidget.
#
# 10) Правило сопровождения:
# Если нужна горизонтальная компоновка внутри GroupBox —
# вкладывать HContainer в add_widget(), не менять _group_layout на QHBoxLayout.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Some files were not shown because too many files have changed in this diff Show More