Загрузка данных
# -*- coding: utf-8 -*-
# gui/indicator_manager.py
import os
import random
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from tkinter import Tk # Этот модуль нужен для доступа к методам буфера обмена
from modules.base_app_window import AppModalWindow, show_modal_dialog
from docx import Document
from docx.shared import Pt
from docx.shared import Pt, Cm
from fpdf import FPDF
from sqlalchemy import Integer, create_engine, func
from sqlalchemy.orm import sessionmaker
# Импортируем из модуля базы данных
from modules.database_module import Indicator
from modules.database_module import Session as DBSession
from modules.database_module import (
delete_indicator_by_code,
get_all_indicators_by_group,
insert_indicator,
list_groups,
)
DEBUG_LOGGING = False
class IndicatorManager(AppModalWindow):
def __init__(self, parent):
super().__init__(parent)
self.parent = parent
self.title("Менеджер показателей")
self.geometry("600x450")
# --- Инициализация и доступ к базе данных ---
if not hasattr(parent, "current_user"):
raise AttributeError("Ошибка: Родительское окно не авторизовано.")
if not self.check_role_access(parent):
messagebox.showwarning(
"Доступ запрещён", "Нужен статус администратора или эксперта."
)
self.iconify() # <-- Заменяем на сворачивание окна
return
self.use_master_db = self.parent.is_master_db_active
# Создаем движок (engine) и сессию ОДИН РАЗ при создании окна
db_path = "masterdata.db" if self.use_master_db.get() else "database.db"
self.engine = create_engine(f"sqlite:///{db_path}", echo=False)
# <-- ЕДИНАЯ СЕССИЯ для всего окна
self.session = DBSession(bind=self.engine)
# Привязываем закрытие окна к методу закрытия сессии
self.protocol("WM_DELETE_WINDOW", self.on_close)
# Словарь для связи названий групп с их кодами
self.group_names_to_codes = {}
# --- Создание интерфейса ---
self.create_widgets()
# Первоначальное заполнение данных
self.update_group_combobox()
self.update_indicators_list()
def paste_from_clipboard(self):
"""Вставляет текст из буфера обмена в поле названия показателя."""
try:
# Получаем текст из системного буфера обмена
clipboard_text = self.parent.clipboard_get()
# Вставляем текст в поле названия
self.name_entry.insert(tk.END, clipboard_text)
except tk.TclError:
# Если буфер обмена пуст или возникла ошибка
messagebox.showwarning("Буфер обмена", "Буфер обмена пуст.")
def create_widgets(self):
"""Метод для создания всех виджетов интерфейса."""
top_panel = tk.Frame(self)
top_panel.pack(padx=10, pady=10, fill="x")
# Выбор группы
tk.Label(top_panel, text="Группа:").pack(anchor="w")
self.group_var = tk.StringVar()
self.group_combobox = ttk.Combobox(
top_panel, textvariable=self.group_var, state="readonly"
)
self.group_combobox.pack(fill="x", pady=5)
self.group_combobox.bind("<<ComboboxSelected>>", self.update_indicators_list)
# Поле ввода названия
tk.Label(top_panel, text="Название показателя:").pack(anchor="w")
self.name_entry = tk.Text(top_panel, height=3, width=40)
self.name_entry.pack(fill="x", pady=5)
# Кнопка вставки из буфера (исправленная логика)
paste_button = tk.Button(
top_panel,
text="Вставить",
command=self.paste_from_clipboard, # Вставляет текст в поле "Название"
)
paste_button.pack() # <-- Эта строка была у вас пропущена
# Кнопки действий
buttons_frame = tk.Frame(top_panel)
buttons_frame.pack(fill="x", pady=5)
add_btn = tk.Button(buttons_frame, text="Добавить", command=self.add_indicator)
add_btn.pack(side="left", padx=2)
del_btn = tk.Button(
buttons_frame, text="Удалить", command=self.delete_indicator
)
del_btn.pack(side="left", padx=2)
# --- ИСПРАВЛЕННЫЙ КОД ---
print_btn = tk.Button(
buttons_frame,
text="Экспорт в Word",
command=self.print_to_docx
)
print_btn.pack(side="left", padx=5, pady=5)
# --- Список показателей со скроллбаром (ИСПРАВЛЕНО) ---
middle_panel = tk.Frame(self)
middle_panel.pack(fill="both", expand=True, padx=10, pady=5)
scrollbar = ttk.Scrollbar(middle_panel)
scrollbar.pack(side="right", fill="y")
# Виджет Treeview для отображения списка
# 1. Изменяем здесь: добавляем 'number' в список колонок
self.indicators_tree = ttk.Treeview(
middle_panel,
columns=("number", "name", "code"), # <-- ИЗМЕНЕНО
show="headings",
yscrollcommand=scrollbar.set,
selectmode="browse"
)
# --- НАСТРОЙКА КОЛОНОК (ПОЛНОСТЬЮ ОБНОВЛЕННЫЙ БЛОК) ---
# 1. Колонка с порядковым номером
self.indicators_tree.heading("number", text="№ п/п")
self.indicators_tree.column("number", width=50, anchor="center")
# 2. Колонка с названием показателя
self.indicators_tree.heading("name", text="Название")
self.indicators_tree.column("name", width=400)
# 3. Колонка с кодом показателя
self.indicators_tree.heading("code", text="Код")
self.indicators_tree.column("code", width=150)
self.indicators_tree.pack(fill="both", expand=True)
scrollbar.config(command=self.indicators_tree.yview)
# Биндинг событий для списка Treeview (ИСПРАВЛЕНО)
# В Treeview выделение строки происходит автоматически по клику.
# Нам нужно только обработать двойной клик для редактирования.
self.indicators_tree.bind("<Double-Button-1>", self.edit_indicator)
def get_clicked_line_number(self, event):
"""Определяет номер строки, на которой был произведён клик."""
# Получаем позицию курсора в формате 'строка.колонка'
cursor_pos = self.indicators_list.index(f"@{event.x},{event.y}")
# Получаем номер строки (целая часть до точки)
return int(cursor_pos.split(".")[0])
def select_whole_line(self, event):
"""Выделяет всю строку и сохраняет код индикатора."""
line_num = self.get_clicked_line_number(event)
line_start = f"{line_num}.0"
line_end = f"{line_num}.end-1c"
self.indicators_list.focus_set()
self.indicators_list.tag_remove("sel", "1.0", "end")
self.indicators_list.tag_add("sel", line_start, line_end)
# --- НОВОЕ: Сохраняем код выделенного индикатора ---
# Ищем строку с кодом (она всегда под строкой с названием)
code_line_start = f"{line_num + 1}.0"
code_line_end = f"{line_num + 1}.end"
# Получаем текст этой строки
code_line_text = self.indicators_list.get(code_line_start, code_line_end)
# Парсим код (например, из строки "Код: MY-GROUP-001")
if code_line_text.startswith("Код: "):
selected_code = code_line_text.split(": ")[1]
# Сохраняем код как атрибут виджета для дальнейшего использования
self.indicators_list.selected_code = selected_code
return "break"
def on_close(self):
"""Корректно закрывает сессию и окно."""
try:
self.session.close()
print("Сессия базы данных закрыта.")
except Exception as e:
print(f"Ошибка при закрытии сессии: {e}")
finally:
self.destroy()
def add_indicator(self):
group_name = self.group_var.get()
group_code = self.group_names_to_codes.get(group_name)
indicator_name = self.name_entry.get("1.0", tk.END).rstrip("\n").strip()
if not group_code or not indicator_name:
messagebox.showwarning("Ошибка", "Выберите группу и введите название!")
return
new_code = self.generate_unique_code(group_code)
new_indicator = Indicator(
code=new_code, name=indicator_name, group_code=group_code
)
self.session.add(new_indicator)
self.session.commit() # Сохраняем в главной сессии
self.name_entry.delete("1.0", tk.END)
self.update_indicators_list()
def generate_unique_code(self, group_code):
"""Генерирует гарантированно уникальный код."""
index = 1
# Цикл будет выполняться, пока не найдется свободный код
while True:
new_code = f"{group_code}-{index}"
# Проверяем в базе, есть ли запись с таким кодом
exists = (
self.session.query(Indicator.code).filter_by(code=new_code).first()
is not None
)
if not exists:
return new_code # Возвращаем первый же свободный код
# Если код занят, пробуем следующий номер по порядку
index += 1
def update_group_combobox(self):
"""Заполняет выпадающий список групп."""
groups = list_groups() # Предполагаем, что эта функция работает с текущей базой
self.group_names_to_codes.clear()
for group in groups:
self.group_names_to_codes[group.name] = group.code
group_names = [group.name for group in groups]
self.group_combobox["values"] = group_names
if group_names:
self.group_combobox.current(0)
self.update_indicators_list() # Обновить список при первой загрузке
def update_indicators_list(self, event=None):
"""Обновляет Treeview списком индикаторов с порядковыми номерами."""
selected_group_name = self.group_var.get()
selected_group_code = self.group_names_to_codes.get(selected_group_name)
# Очищаем дерево перед обновлением
for item in self.indicators_tree.get_children():
self.indicators_tree.delete(item)
if not selected_group_code:
return # Если группа не выбрана, просто выходим после очистки
indicators = self.session.query(Indicator).filter_by(group_code=selected_group_code).all()
# Используем enumerate для получения порядкового номера
for idx, ind in enumerate(indicators, start=1):
# Вставляем данные. Теперь первым в values идет порядковый номер.
self.indicators_tree.insert(
"", "end", iid=ind.code, values=(f"{idx}.", ind.name, ind.code)
)
def edit_indicator(self, event):
"""Открывает диалог для редактирования выбранного индикатора из Treeview."""
# Получаем ID (iid) выделенного элемента.
selected_items = self.indicators_tree.selection()
if not selected_items:
return # Если ничего не выделено
code = selected_items[0] # ID выделенной строки (это код индикатора)
indicator = self.session.query(Indicator).filter_by(code=code).first()
if indicator:
edit_win = tk.Toplevel(self)
edit_win.title("Редактировать")
edit_win.geometry("350x150")
tk.Label(edit_win, text="Новое название:").pack(pady=5)
new_name_entry = tk.Text(edit_win, height=2, width=40)
new_name_entry.insert(tk.END, indicator.name) # Заполняем поле текущим названием
new_name_entry.pack(pady=5)
save_btn = tk.Button(
edit_win,
text="Сохранить",
command=lambda: self.save_changes(code, new_name_entry.get("1.0", tk.END), edit_win),
)
save_btn.pack(pady=5)
def save_changes(self, code, new_name_raw, edit_win):
"""Сохраняет изменения в базу данных."""
new_name = new_name_raw.rstrip("\n").strip()
if not new_name:
messagebox.showwarning("Ошибка", "Название не может быть пустым.")
return
# Используем главную сессию!
indicator = self.session.query(Indicator).filter_by(code=code).first()
if indicator:
indicator.name = new_name
self.session.commit() # Сохраняем в главной сессии
self.update_indicators_list()
messagebox.showinfo("Готово", "Изменения сохранены.")
edit_win.destroy()
else:
messagebox.showwarning("Ошибка", "Индикатор не найден.")
def delete_indicator(self):
"""Удаляет выбранный в Treeview индикатор."""
selected_items = self.indicators_tree.selection()
if not selected_items:
messagebox.showwarning("Ошибка", "Выберите индикатор для удаления.")
return
code = selected_items[0]
if messagebox.askyesno("Подтверждение", f"Удалить индикатор с кодом {code}?"):
success = delete_indicator_by_code(code)
if success:
messagebox.showinfo("Готово", "Удалено успешно.")
self.update_indicators_list() # Обновляем список после удаления
else:
messagebox.showerror("Ошибка", "Не удалось удалить.")
def print_to_docx(self):
"""Экспортирует список индикаторов в DOCX (Word) в виде отформатированной таблицы."""
group_name = self.group_var.get()
if not group_name:
messagebox.showwarning("Ошибка", "Выберите группу.")
return
group_code = self.group_names_to_codes.get(group_name)
indicators = (
self.session.query(Indicator).filter_by(group_code=group_code).all()
)
if not indicators:
messagebox.showwarning("Ошибка", "В группе нет показателей.")
return
# --- НАЧАЛО ФОРМАТИРОВАНИЯ ДОКУМЕНТА ---
# Создание документа
doc = Document()
# 1. Настройка шрифта и полей
# * Шрифт Times New Roman, 12 пт, черный (черный - по умолчанию)
style = doc.styles['Normal']
font = style.font
font.name = 'Times New Roman'
font.size = Pt(12)
# * Поля: 2 см (слева/справа), 3 см (сверху), 1,5 см (снизу)
# * Примечание: python-docx не имеет прямого API для установки полей.
# Стандартный способ - через секцию (section).
section = doc.sections[0]
section.left_margin = Cm(2)
section.right_margin = Cm(2)
section.top_margin = Cm(3)
section.bottom_margin = Cm(1.5)
# 2. Добавление заголовка документа
title_paragraph = doc.add_paragraph()
title_run = title_paragraph.add_run(f"Группа: {group_name}")
title_run.bold = True
title_paragraph.alignment = 1 # 1 = WD_ALIGN_PARAGRAPH.CENTER
# Добавление пустого абзаца для отступа перед таблицей
doc.add_paragraph()
# 3. Создание и форматирование таблицы
# * Создаем таблицу с 3 колонками и строкой заголовка (header=True)
table = doc.add_table(rows=1, cols=3, style='Table Grid')
table.autofit = False # Отключаем автоматическое изменение ширины столбцов
# * Заполняем строку заголовка
hdr_cells = table.rows[0].cells
hdr_cells[0].text = '№ п/п'
hdr_cells[1].text = 'Код'
hdr_cells[2].text = 'Показатель'
# * Применяем стиль к строке заголовка (полужирный шрифт)
for cell in hdr_cells:
for paragraph in cell.paragraphs:
for run in paragraph.runs:
run.bold = True
# * Заполняем таблицу данными из базы
for idx, ind in enumerate(indicators, start=1):
row_cells = table.add_row().cells
row_cells[0].text = str(idx)
row_cells[1].text = ind.code
row_cells[2].text = ind.name
# * Устанавливаем фиксированную ширину для столбцов
# Столбец "№ п/п" - узкий, "Код" - средний, "Показатель" - широкий.
for cell in table.columns[0].cells:
cell.width = Cm(2.0)
for cell in table.columns[1].cells:
cell.width = Cm(3.0)
for cell in table.columns[2].cells:
cell.width = Cm(13.0)
# --- КОНЕЦ ФОРМАТИРОВАНИЯ ДОКУМЕНТА ---
# Генерация имени файла и диалог сохранения
filename = f"{group_name.replace(' ', '_')}_indicators.docx"
path = filedialog.asksaveasfilename(
initialfile=filename,
defaultextension=".docx",
filetypes=[("Word files", "*.docx"), ("All files", "*.*")],
)
if path:
try:
doc.save(path)
messagebox.showinfo(
"Готово", f"Файл сохранен как:\n{os.path.basename(path)}"
)
except Exception as e:
messagebox.showerror("Ошибка сохранения", str(e))
def check_role_access(self, parent):
user_roles = {"Администратор", "Эксперт"}
current_user = getattr(parent, "current_user", {})
role = current_user.get("role")
return bool(role) and role in user_roles
if __name__ == "__main__":
root = tk.Tk()
# Для теста создаем фиктивного родителя с нужными атрибутами
class FakeParent:
def __init__(self):
self.current_user = {"role": "Администратор"}
self.is_master_db_active = tk.BooleanVar(value=False)
fake_parent = FakeParent()
manager = IndicatorManager(fake_parent)
root.mainloop()