Add Dispatch_V0.1.1
BIN
Dispatch_V0.1.1/__pycache__/auth_service.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/__pycache__/error_logger.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/__pycache__/main.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/__pycache__/ticket_plugin.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/__pycache__/window.cpython-313.pyc
Normal file
20
Dispatch_V0.1.1/application/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
BIN
Dispatch_V0.1.1/application/__pycache__/__init__.cpython-313.pyc
Normal file
80
Dispatch_V0.1.1/application/archive_service.py
Normal 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")
|
||||||
230
Dispatch_V0.1.1/application/document_flow_service.py
Normal 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"
|
||||||
|
)
|
||||||
50
Dispatch_V0.1.1/application/report_signing_service.py
Normal 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
|
||||||
|
)
|
||||||
42
Dispatch_V0.1.1/application/specialist_assignment_service.py
Normal 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())
|
||||||
321
Dispatch_V0.1.1/application/task_application_service.py
Normal 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
|
||||||
115
Dispatch_V0.1.1/application/ticket_application_api.py
Normal 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."""
|
||||||
180
Dispatch_V0.1.1/auth_service.py
Normal 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
|
||||||
5
Dispatch_V0.1.1/dispatch.bat
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
cd /d "%~dp0"
|
||||||
|
python main.py
|
||||||
|
pause
|
||||||
43
Dispatch_V0.1.1/domain/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
BIN
Dispatch_V0.1.1/domain/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/domain/__pycache__/task.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/domain/__pycache__/ticket_types.cpython-313.pyc
Normal file
54
Dispatch_V0.1.1/domain/location_catalog.py
Normal 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
|
||||||
168
Dispatch_V0.1.1/domain/task.py
Normal 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,
|
||||||
|
)
|
||||||
41
Dispatch_V0.1.1/domain/ticket_constants.py
Normal 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",
|
||||||
|
}
|
||||||
208
Dispatch_V0.1.1/domain/ticket_state_service.py
Normal 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
|
||||||
53
Dispatch_V0.1.1/domain/ticket_transition_policy.py
Normal 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)
|
||||||
92
Dispatch_V0.1.1/domain/ticket_types.py
Normal 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
|
||||||
268
Dispatch_V0.1.1/error_logger.py
Normal 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,
|
||||||
|
)
|
||||||
42
Dispatch_V0.1.1/gui/__init__.py
Normal 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__, но импортируется).
|
||||||
BIN
Dispatch_V0.1.1/gui/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/gui/__pycache__/login_dialog.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/gui/__pycache__/theme_bus.cpython-313.pyc
Normal file
73
Dispatch_V0.1.1/gui/components/__init__.py
Normal 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, ...
|
||||||
BIN
Dispatch_V0.1.1/gui/components/__pycache__/label.cpython-313.pyc
Normal file
132
Dispatch_V0.1.1/gui/components/_tree_node_building.py
Normal 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, '')
|
||||||
235
Dispatch_V0.1.1/gui/components/_tree_state_management.py
Normal 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
|
||||||
280
Dispatch_V0.1.1/gui/components/button.py
Normal 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().
|
||||||
239
Dispatch_V0.1.1/gui/components/color_palette.py
Normal 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.
|
||||||
153
Dispatch_V0.1.1/gui/components/color_swatch.py
Normal 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.
|
||||||
281
Dispatch_V0.1.1/gui/components/combo_box.py
Normal 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().
|
||||||
248
Dispatch_V0.1.1/gui/components/coordinate_input.py
Normal 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 + суффиксы.
|
||||||
102
Dispatch_V0.1.1/gui/components/dialog.py
Normal 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)
|
||||||
279
Dispatch_V0.1.1/gui/components/double_spin_box.py
Normal 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.
|
||||||
240
Dispatch_V0.1.1/gui/components/group_box.py
Normal 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.
|
||||||
BIN
Dispatch_V0.1.1/gui/components/icons/accept_wolume_black.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/accept_wolume_white.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/camera_fixation_black.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/camera_fixation_white.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_mesh_black.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_mesh_white.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_volume_black.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_volume_white.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_zone_black.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/create_zone_white.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/delete_mesh_black.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/delete_mesh_white.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/delete_zone_black.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/delete_zone_white.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/edit_black.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/edit_white.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/logo_usms.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/measure_black.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/measure_white.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/remember_point_black.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/remember_point_white.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
Dispatch_V0.1.1/gui/components/icons/select_zone_black.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |