Загрузка данных
Добрый день.
Делаем рефакторинг после рефакторинга, поэтому очень все запутанно.
Сначала покажу что есть у меня после крайнего рефакторинга.
# ============================================================
# ============================================================
# -*- coding: utf-8 -*-
# Файл: modules/project_list.py
# ============================================================
import os
import tkinter as tk
from tkinter import Toplevel, messagebox, ttk
from modules.database_module import Session, get_all_indicators_by_group, list_groups
class ProjectListWindow:
"""
Окно для выбора проекта, группы и показателя.
Не содержит логики работы с событиями.
"""
def __init__(self, manager, root_window=None):
self.manager = manager
self.root_window = root_window
self.window = Toplevel(root_window)
self.window.title("Выбор параметров проекта")
self.window.geometry("500x250")
self.window.resizable(False, False)
self.session = Session()
self.group_names_to_codes = {}
self.name_to_code_map = {}
self._create_widgets()
self.update_project_list()
# Если проекты есть, загружаем данные для первого из них
if self.project_combobox["values"]:
self.project_combobox.current(0)
self._load_data()
def _create_widgets(self):
main_frame = tk.Frame(self.window)
main_frame.pack(padx=10, pady=10, fill="both", expand=True)
# Выбор проекта
tk.Label(main_frame, text="Проект:").pack(anchor="w", pady=(0, 2))
self.project_combobox = ttk.Combobox(main_frame, state="readonly")
self.project_combobox.pack(fill="x", padx=15, pady=5)
self.project_combobox.bind("<<ComboboxSelected>>", self._load_data)
# Выбор Группы
tk.Label(main_frame, text="Группа:").pack(anchor="w")
self.group_combobox = ttk.Combobox(main_frame, state="readonly")
self.group_combobox.bind("<<ComboboxSelected>>", self.update_indicators_list)
self.group_combobox.pack(fill="x", padx=15, pady=5)
# Выбор Показателя
tk.Label(main_frame, text="Показатель:").pack(anchor="w")
self.indicator_combobox = ttk.Combobox(main_frame, state="readonly")
self.indicator_combobox.pack(fill="x", padx=15, pady=5)
def update_project_list(self):
"""Обновляет список проектов."""
projects = self.manager.list_projects()
try:
display_names = []
self.name_to_code_map = {}
for p in projects:
display_names.append(p.name)
self.name_to_code_map[p.name] = p.id
self.project_combobox["values"] = display_names
if display_names:
self.project_combobox.set(display_names[0])
self.window.after(0, self._load_data)
return bool(display_names)
except Exception as e:
print(f"Ошибка загрузки проектов: {e}")
return False
def _on_project_select(self, event):
"""
Этот метод вызывается, когда пользователь выбирает элемент в Combobox.
Он нужен, чтобы принудительно перерисовать текст в поле.
"""
# Получаем выбранный объект Project
selected_object = self.project_combobox.get()
# Если выбранный объект существует и у него есть атрибут 'name'
if hasattr(selected_object, "name"):
self.project_combobox.set(selected_object.name)
try:
# Создаем карту "Имя -> Объект Project" для быстрого доступа
self.project_objects_map = {}
project_objects = []
for p in projects:
# Ключ - имя, значение - объект
self.project_objects_map[p.name] = p
project_objects.append(p)
self.project_combobox.bind("<<ComboboxSelected>>", self._on_project_select)
# Если список не пуст, выбираем первый элемент
if project_objects:
self.project_combobox.set(project_objects[0])
self._load_data()
return bool(project_objects)
except Exception as e:
print(f"Ошибка загрузки проектов: {e}")
return False
def _load_data(self, event=None):
"""Загружает данные (группы и показатели)."""
# Получаем выбранное ИМЯ из списка
selected_human_name = self.project_combobox.get()
if not selected_human_name:
# Очистка списков, если ничего не выбрано
self.group_combobox.set("")
self.indicator_combobox.set("")
self.group_combobox["values"] = []
self.indicator_combobox["values"] = []
self.group_names_to_codes.clear()
return
# print(
# f"[ДИАГНОСТИКА] Пользователь выбрал проект: '{selected_human_name}'. Загружаем данные..."
# )
try:
# --- 1. ИЩЕМ КОД ПО ИМЕНИ ---
# Используем карту, которую мы создали в update_project_list
project_code = self.name_to_code_map.get(selected_human_name)
if not project_code:
print(
f"[ОШИБКА] Код для имени '{selected_human_name}' не найден в карте."
)
return
# print(f"[ДИАГНОСТИКА] Данные для кода: {project_code}")
# --- 2. ЛОГИКА ЗАГРУЗКИ ГРУПП И ПОКАЗАТЕЛЕЙ ---
groups = list_groups(session=self.session)
if groups:
group_names = [group.name for group in groups]
self.group_combobox["values"] = group_names
if group_names:
first_group_name = group_names[0]
self.group_combobox.set(first_group_name)
self.group_names_to_codes.clear()
for group in groups:
self.group_names_to_codes[group.name] = group.code
first_group_code = self.group_names_to_codes[first_group_name]
indicators = get_all_indicators_by_group(
first_group_code, session=self.session
)
if indicators:
indicator_names = [i.name for i in indicators]
self.indicator_combobox["values"] = indicator_names
if indicator_names:
self.indicator_combobox.set(indicator_names[0])
except Exception as e:
print(f"[ОШИБКА] Не удалось загрузить данные: {e}")
def update_indicators_list(self, event=None):
"""Обновляет список показателей при выборе новой группы."""
selected_group_name = self.group_combobox.get()
if (
not selected_group_name
or selected_group_name not in self.group_names_to_codes
):
self.indicator_combobox.set("")
self.indicator_combobox["values"] = []
return
try:
selected_group_code = self.group_names_to_codes.get(selected_group_name)
if selected_group_code:
indicators = get_all_indicators_by_group(
selected_group_code, session=self.session
)
if indicators and len(indicators) > 0:
indicator_names = [i.name for i in indicators]
self.indicator_combobox["values"] = indicator_names
# Устанавливаем первый показатель по умолчанию
if indicator_names and (
not self.indicator_combobox.get()
or self.indicator_combobox.get() not in indicator_names
):
self.indicator_combobox.set(indicator_names[0])
else:
self.indicator_combobox["values"] = []
self.indicator_combobox.set("")
except Exception as e:
print(f"[ОШИБКА] Не удалось загрузить показатели: {e}")
def get_selected_data(self):
"""
Возвращает словарь с выбранными данными.
Теперь гарантированно возвращает 'indicator_code'.
"""
# Получаем выбранные имена из интерфейса
project_display_name = self.project_combobox.get()
group_name = self.group_combobox.get()
indicator_name = self.indicator_combobox.get()
# Получаем коды для проекта и группы из наших карт
project_code = self.name_to_code_map.get(project_display_name)
group_code = self.group_names_to_codes.get(group_name)
# --- НОВАЯ ЛОГИКА: Поиск КОДА показателя ---
indicator_code = None
# 1. Проверяем, что мы смогли получить код группы.
if group_code:
# 2. Запрашиваем список ВСЕХ показателей для этой группы из БД.
# Используем уже существующий метод, который работает с SQLAlchemy
indicators = get_all_indicators_by_group(group_code, session=self.session)
# 3. Ищем в этом списке тот показатель, имя которого совпадает с
# выбранным в combobox.
if indicators:
for indicator in indicators:
# Сравниваем имя из базы с именем, которое выбрал
# пользователь
if indicator.name == indicator_name:
# Нашли совпадение! Берем КОД из объекта.
indicator_code = indicator.code
break # Выходим из цикла, так как нашли нужное.
# --- ВОЗВРАЩАЕМ РЕЗУЛЬТАТ ---
return {
"project_name": project_display_name,
"project_code": project_code,
"group_name": group_name,
"group_code": group_code,
"indicator_name": indicator_name,
"indicator_code": indicator_code, # <-- Теперь этот ключ будет в словаре!
}
# ============================================================
# ============================================================
# -*- coding: utf-8 -*-
# project_create.py
# ============================================================
import datetime
import os
import tkinter as tk
from tkinter import Toplevel, messagebox, ttk
from gui.project_manager import Project, ProjectManager
from modules.project_database import ProjectDatabase
class CreateProjectWindow:
"""
Диалоговое окно для создания нового проекта.
"""
MIN_CODE_LENGTH = 3
MAX_NAME_LENGTH = 100
def __init__(self, manager: ProjectManager):
"""
:param manager: Экземпляр ProjectManager для добавления проекта.
"""
self.manager = manager
self.window = Toplevel()
# Эта команда делает так, чтобы при закрытии диалогового окна
# фокус автоматически возвращался к главному окну.
self.window.transient(self.window.master)
self.window.title("Создание нового проекта")
self.window.geometry("450x300")
self.window.resizable(False, False)
# Главный фрейм с сеткой для аккуратного размещения
main_frame = ttk.Frame(self.window, padding="10")
main_frame.pack(fill="both", expand=True)
main_frame.columnconfigure(1, weight=1) # Второй столбец растягивается
# --- 1. Поля ввода ---
ttk.Label(main_frame, text="Название проекта:").grid(
row=0, column=0, padx=5, pady=5, sticky="e"
)
self.name_entry = ttk.Entry(main_frame, width=40)
self.name_entry.grid(row=0, column=1, padx=5, pady=5, sticky="we")
ttk.Label(main_frame, text="Код проекта:").grid(
row=1, column=0, padx=5, pady=5, sticky="e"
)
self.code_entry = ttk.Entry(main_frame, width=40)
self.code_entry.grid(row=1, column=1, padx=5, pady=5, sticky="we")
ttk.Label(main_frame, text="Описание:").grid(
row=2, column=0, padx=5, pady=5, sticky="e"
)
self.desc_entry = ttk.Entry(main_frame, width=40)
self.desc_entry.grid(row=2, column=1, padx=5, pady=5, sticky="we")
ttk.Label(main_frame, text="Дата открытия:").grid(
row=3, column=0, padx=5, pady=5, sticky="e"
)
self.date_entry = ttk.Entry(main_frame, width=15)
default_date = datetime.datetime.now().strftime("%Y-%m-%d")
self.date_entry.insert(0, default_date)
self.date_entry.grid(row=3, column=1, padx=5, pady=5)
ttk.Label(main_frame, text="Ответственный:").grid(
row=4, column=0, padx=5, pady=5, sticky="e"
)
self.resp_entry = ttk.Entry(main_frame, width=40)
self.resp_entry.grid(row=4, column=1, padx=5, pady=5, sticky="we")
# --- 2. Кнопки действий ---
# Фрейм для выравнивания кнопок справа внизу окна
button_frame = ttk.Frame(self.window)
button_frame.pack(pady=(20, 10), fill="x")
# Кнопка "Отмена"
ttk.Button(button_frame, text="Отмена", command=self.window.destroy).pack(
side="right", padx=(0, 10)
)
# Кнопка "Создать"
ttk.Button(button_frame, text="Создать", command=self.create_project).pack(
side="right"
)
def create_project(self):
"""Обрабатывает нажатие кнопки 'Создать'."""
# Получаем данные из формы
human_name = self.name_entry.get() # Название проекта
project_code = self.code_entry.get() # Код проекта
# Проверка на пустые поля
if not human_name or not project_code:
messagebox.showerror("Ошибка", "Введите название и код проекта!")
return
try:
# --- 1. Проверка существования базы данных (ПОДАВЛЕНИЕ ВЫВОДА) ---
# Создаем объект БД внутри блока с перенаправлением stdout,
# чтобы скрыть служебные сообщения о создании папки и подключении.
import sys
import os
# Сохраняем стандартный вывод
old_stdout = sys.stdout
# Перенаправляем вывод в "пустоту"
sys.stdout = open(os.devnull, "w")
# Создаем объект и сразу пытаемся подключиться.
# Все print() внутри ProjectDatabase.__init__ и .connect() будут скрыты.
project_db = ProjectDatabase(project_code)
is_db_new = not os.path.exists(project_db.db_path) # Флаг: новая ли это БД?
conn = project_db.connect()
# Возвращаем стандартный вывод
sys.stdout = old_stdout
# Если база уже существует, показываем ошибку
if not is_db_new:
project_db.close() # Закрываем соединение перед возвратом
messagebox.showerror(
"Ошибка", f"База данных для кода '{project_code}' уже существует!"
)
return
# --- 2. Создание объекта Project и заполнение данных ---
project = Project(name=human_name)
project.id = project_code # Задаем ID (код проекта)
# Заполняем остальные поля из формы
project.description = self.desc_entry.get()
project.responsible = self.resp_entry.get()
project.date_opened = self.date_entry.get()
# --- 3. Сохранение проекта через менеджер ---
success = self.manager.add_project(project)
if success:
messagebox.showinfo("Успех", f"Проект '{human_name}' успешно создан!")
# Создаем таблицы в новой базе данных, если проект был добавлен
project_db.create_tables()
else:
messagebox.showerror("Ошибка", "Не удалось создать проект.")
except Exception as e:
messagebox.showerror(
"Критическая ошибка", f"Не удалось создать проект:\n{str(e)}"
)
finally:
self.window.destroy()
# ============================================================
# ================================================================
# -*- coding: utf-8 -*-
# Файл: modules/project_edit.py
# ============================================================
import tkinter as tk
from tkinter import Button, Radiobutton, Toplevel, messagebox, ttk
# Импортируем только функции и движок
from modules.database_module import engine, get_all_indicators_by_group, list_groups
class EditProjectWindow:
"""
Класс окна редактирования проекта.
Сессия SQLAlchemy открывается один раз при инициализации окна.
"""
def __init__(self, manager, root_window=None):
self.manager = manager # <-- НОВОЕ: Сохраняем ссылку на менеджер
self.root_window = root_window
self.window = Toplevel(root_window)
self.window.title("Редактирование проекта")
self.window.geometry("600x500")
self.window.protocol("WM_DELETE_WINDOW", self.on_close)
# --- ИЗМЕНЕНИЕ: Убираем создание своего движка ---
# self.engine = create_engine("sqlite:///database.db", echo=False)
# self.Session = sessionmaker(bind=self.engine)
# --- НОВОЕ: Используем сессию из основной базы (как было раньше) ---
from modules.database_module import Session
self.session = Session()
# ------------------------------------
self.group_names_to_codes = {}
self._create_widgets()
# Загружаем список проектов сразу
self.update_project_list()
# Если проекты есть, сразу грузим данные для первого из них
if self.project_combobox["values"]:
self._load_data()
# Словарь для быстрого поиска: Название проекта -> Код проекта (ID)
self.name_to_code_map = {}
def _create_widgets(self):
main_frame = tk.Frame(self.window)
main_frame.pack(padx=10, pady=2, fill="both", expand=True)
# 1. Выбор проекта
tk.Label(main_frame, text="Выбрать проект:").pack(anchor="w")
self.project_combobox = ttk.Combobox(main_frame, state="readonly")
self.project_combobox.pack(fill="x", padx=15)
self.project_combobox.bind("<<ComboboxSelected>>", self._load_data)
# 2. Выбор Группы
tk.Label(main_frame, text="Группа:").pack(anchor="w")
self.group_combobox = ttk.Combobox(main_frame, state="readonly")
self.group_combobox.bind("<<ComboboxSelected>>", self.update_indicators_list)
self.group_combobox.pack(fill="x", padx=15)
# 3. Выбор Показателя
tk.Label(main_frame, text="Показатель:").pack(anchor="w")
self.indicator_combobox = ttk.Combobox(main_frame, state="readonly")
self.indicator_combobox.pack(fill="x", padx=15)
# 4. Поле "Дата события"
date_frame = tk.Frame(main_frame)
date_frame.pack(fill="x", pady=3)
tk.Label(date_frame, text="Дата события:").pack(side="left")
self.date_entry = tk.Entry(date_frame, width=12)
self.date_entry.insert(0, "2026-05-08")
self.date_entry.pack(side="left", padx=(5, 0), ipadx=5)
# 5. Радиокнопки "Направленность"
direction_frame = tk.Frame(main_frame)
direction_frame.pack(fill="x", pady=3, anchor="w")
tk.Label(direction_frame, text="Направленность:").pack(side="left")
radio_frame = tk.Frame(direction_frame)
radio_frame.pack(side="left", padx=(5, 0))
self.direction_var = tk.IntVar(value=1)
Radiobutton(
radio_frame, text="+ К цели", variable=self.direction_var, value=1
).pack(side="left", padx=(0, 10))
Radiobutton(
radio_frame, text="- От цели", variable=self.direction_var, value=-1
).pack(side="left", padx=(0, 10))
# 6. Поле "Событие"
tk.Label(main_frame, text="Событие:").pack(anchor="w")
self.event_text = tk.Text(main_frame, height=4, wrap="word")
self.event_text.pack(fill="x", pady=(2, 5), padx=15)
# Кнопка "Вставить из буфера"
btn_frame = tk.Frame(main_frame)
btn_frame.pack(anchor="e", padx=15, pady=(0, 5))
Button(
btn_frame, text="Вставить", command=self.paste_from_clipboard, width=10
).pack()
# Блок кнопок управления (Добавить, Удалить...)
ctrl_btns = tk.Frame(main_frame)
ctrl_btns.pack(fill="x", pady=(5, 3)) # Отступ сверху стал меньше
Button(ctrl_btns, text="Добавить", width=15, command=self.add_entry).pack(
side="left", padx=(0, 10)
)
Button(ctrl_btns, text="Удалить", width=15).pack(side="left", padx=(10, 10))
Button(ctrl_btns, text="Печать в Word", width=15).pack(
side="left", padx=(10, 10)
)
Button(ctrl_btns, text="В архив", width=15, command=self.archive_project).pack(
side="left", padx=(10, 0)
)
# --- НОВЫЙ БЛОК: Список событий ---
events_frame = tk.Frame(main_frame)
events_frame.pack(
fill="both", expand=True, pady=(5, 0), padx=15
) # Отступ сверху стал меньше
tk.Label(events_frame, text="Список событий:").pack(anchor="w")
# Создаем виджет для списка (Listbox) с полосой прокрутки (Scrollbar)
self.events_listbox = tk.Listbox(events_frame, height=6)
self.events_listbox.pack(side="left", fill="both", expand=True)
scrollbar = tk.Scrollbar(
events_frame, orient="vertical", command=self.events_listbox.yview
)
self.events_listbox.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side="right", fill="y")
def update_project_list(self):
"""Обновляет список проектов."""
projects = self.manager.list_projects()
try:
names = [p.name for p in projects]
self.project_combobox["values"] = names
return bool(names) # Вернем True/False для удобства
except Exception as e:
print(f"Ошибка: {e}")
return False
def _load_data(self, event=None):
"""Загружает данные (группы и показатели) при выборе проекта."""
# Получаем имя выбранного проекта
project_name = self.project_combobox.get()
# --- ОЧИЩАЕМ ВСЁ, ЕСЛИ ПРОЕКТ НЕ ВЫБРАН ---
if not project_name:
self.group_combobox.set("")
self.indicator_combobox.set("")
self.group_combobox["values"] = []
self.indicator_combobox["values"] = []
self.group_names_to_codes.clear() # Очищаем словарь групп
return
print("[ДИАГНОСТИКА] --- ЗАГРУЗКА ДАННЫХ ПО ПРОЕКТУ ---")
try:
# Получаем список объектов проектов (нужно для словаря)
project_objects = self.manager.list_projects()
# --- СОЗДАЕМ СЛОВАРЬ: Название -> Код (ID) ---
self.name_to_code_map.clear() # Очищаем на всякий случай
for p in project_objects:
# Сохраняем соответствие: Название проекта -> его Код (ID)
self.name_to_code_map[p.name] = p.id
# --- ЗАГРУЗКА ДАННЫХ ИЗ ОСНОВНОЙ БАЗЫ (группы и показатели) ---
groups = list_groups(session=self.session)
if groups:
group_names = [group.name for group in groups]
self.group_combobox["values"] = group_names
if group_names:
# Выбираем первую группу по умолчанию
first_group_name = group_names[0]
self.group_combobox.set(first_group_name)
# Обновляем словарь имен и кодов групп
self.group_names_to_codes.clear()
for group in groups:
self.group_names_to_codes[group.name] = group.code
# Загружаем показатели для первой группы
first_group_code = self.group_names_to_codes[first_group_name]
indicators = get_all_indicators_by_group(
first_group_code, session=self.session
)
if indicators:
indicator_names = [indicator.name for indicator in indicators]
self.indicator_combobox["values"] = indicator_names
if indicator_names:
self.indicator_combobox.set(indicator_names[0])
except Exception as e:
print(f"[ОШИБКА] Не удалось загрузить данные: {e}")
def update_indicators_list(self, event=None):
"""Обновляет список показателей при выборе новой группы."""
selected_group_name = self.group_combobox.get()
# Если группа не выбрана, очищаем список показателей
if not selected_group_name:
self.indicator_combobox.set("")
self.indicator_combobox["values"] = []
return
print(
f"[ДИАГНОСТИКА] Пользователь выбрал группу: '{selected_group_name}'. Загружаем показатели..."
)
try:
selected_group_code = self.group_names_to_codes.get(selected_group_name)
if selected_group_code:
indicators = get_all_indicators_by_group(
selected_group_code, session=self.session
)
if indicators:
indicator_names = [indicator.name for indicator in indicators]
self.indicator_combobox["values"] = indicator_names
if indicator_names:
# Сохраняем текущий выбор, если он есть в новом списке
current_val = self.indicator_combobox.get()
if current_val and current_val in indicator_names:
pass
else:
self.indicator_combobox.set(indicator_names[0])
print(
f"[ДИАГНОСТИКА] Список показателей обновлен: {indicator_names}"
)
else:
print("[ДИАГНОСТИКА] Показателей для этой группы нет.")
else:
print("[ДИАГНОСТИКА] Показателей для этой группы нет.")
# Очищаем поле и список, если показателей нет
if not indicators or len(indicators) == 0:
self.indicator_combobox.set("")
self.indicator_combobox["values"] = []
else:
print("[ДИАГНОСТИКА] Код группы не найден в словаре.")
self.indicator_combobox.set("")
self.indicator_combobox["values"] = []
except Exception as e:
print(f"[ОШИБКА] Не удалось загрузить показатели: {e}")
def add_entry(self):
"""Обработка нажатия кнопки 'Добавить'."""
# --- ИМПОРТЫ ---
import os
import sqlite3
# --- 1. ПРОВЕРКА ВХОДНЫХ ДАННЫХ ---
project_name = self.project_combobox.get()
if not project_name:
messagebox.showwarning("Ошибка", "Выберите проект.")
return
group_name = self.group_combobox.get()
if not group_name:
messagebox.showwarning("Ошибка", "Выберите группу.")
return
indicator_name = self.indicator_combobox.get()
if not indicator_name:
messagebox.showwarning("Ошибка", "Выберите показатель.")
return
event_text_content = self.event_text.get("1.0", tk.END).strip()
if not event_text_content:
messagebox.showwarning("Ошибка", "Поле 'Событие' пустое.")
return
# --- 2. ПОДГОТОВКА ДАННЫХ ---
from modules.database_module import Group, Indicator
# Получаем данные ГРУППЫ из основной БД
group_db = self.session.query(Group).filter_by(name=group_name).first()
if not group_db:
messagebox.showerror(
"Критическая ошибка", "Группа не найдена в базе данных."
)
return
group_kov = group_db.kov
# Получаем код ПОКАЗАТЕЛЯ
indicator_db = (
self.session.query(Indicator).filter_by(name=indicator_name).first()
)
if not indicator_db:
messagebox.showerror("Критическая ошибка", "Показатель не найден.")
return
indicator_code = indicator_db.code
direction = self.direction_var.get()
date_event = self.date_entry.get()
# --- 3. ЗАПИСЬ В БАЗУ (внутри try/except) ---
try:
# Получаем список проектов от менеджера и ищем нужный объект
project_objects = self.manager.list_projects()
found_project = None
for p in project_objects:
if p.name == project_name:
found_project = p
break
if not found_project:
messagebox.showerror(
"Ошибка", f"Проект '{project_name}' не найден в менеджере."
)
return
project_code = found_project.id
# Строим путь к базе данных проекта по коду (ID)
db_folder = os.path.join(os.getcwd(), "project_dbs")
db_path = os.path.join(db_folder, f"{project_code}.db")
if not os.path.exists(db_path):
messagebox.showerror(
"Ошибка", f"База данных не найдена по пути: {db_path}"
)
return
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Подсчет количества событий для генерации кода (IV-CODE_Номер)
cursor.execute(
"""
SELECT COUNT(*) FROM project_data
WHERE project_code = ? AND ivent_code LIKE ?
""",
(project_code, f"{indicator_code}_%"),
)
count = cursor.fetchone()[0]
event_code = f"{indicator_code}_{count + 1}"
cursor.execute(
"""
INSERT INTO project_data (
project_code, created_at, group_name, group_kov,
indicator_name, indicator_code, ivent_data, ivent_sign,
ivent_code, event_text_content
) VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
project_code,
group_name,
group_kov,
indicator_name,
indicator_code,
date_event,
direction,
event_code,
event_text_content,
),
)
conn.commit()
# --- 4. ОБНОВЛЕНИЕ ИНТЕРФЕЙСА (только если запись в БД успешна) ---
# Этот код находится ВНУТРИ блока try, поэтому выполнится только
# при успехе.
# Добавляем запись в список на экране
self.events_listbox.insert(
tk.END, f"{event_code}: {event_text_content[:40]}..."
)
# Очищаем поле события после добавления
self.event_text.delete("1.0", tk.END)
# Показываем сообщение об успехе
messagebox.showinfo(
"Успех", f"Данные добавлены.\nКод события: {event_code}"
)
except Exception as e:
# Если что-то пошло не так (например, нет таблицы или ошибка записи),
# мы выводим сообщение об ошибке и ВЫХОДИМ из метода.
# Код обновления интерфейса выше не выполнится.
messagebox.showerror("Ошибка БД", f"Не удалось записать данные: {e}")
return # <--- МАРКЕР: ЗДЕСЬ ПРОИСХОДИТ ВОЗВРАТ ПРИ ОШИБКЕ!
finally:
if "conn" in locals():
conn.close()
def paste_from_clipboard(self):
"""Вставляет текст из буфера обмена в поле события."""
try:
txt = self.window.clipboard_get()
# Вставляем текст в текущую позицию курсора или в начало поля
self.event_text.insert(tk.INSERT, txt)
except Exception as e:
pass
def archive_project(self):
"""Обработка нажатия кнопки 'В архив'."""
name = self.project_combobox.get()
if not name:
messagebox.showwarning("Ошибка", "Выберите проект.")
return
messagebox.showinfo("Успех", f"Проект '{name}' архивирован.")
def on_close(self):
"""Корректно закрывает сессию базы данных при закрытии окна."""
try:
if hasattr(self, "session") and self.session is not None:
self.session.close()
print("[ДИАГНОСТИКА] Сессия базы данных закрыта.")
# Это обязательно для модальных окон (grab_set), чтобы избежать
# зависаний главного окна
if hasattr(self.window, "destroy"):
self.window.destroy()
except Exception as e:
print(f"[ДИАГНОСТИКА] Ошибка при закрытии сессии: {e}")
# ================================================================
# ==========================================================
# -*- coding: utf-8 -*-
# Файл: modules/project_event.py
# ==========================================================
import os
import sqlite3
import tkinter as tk
from tkinter import Button, Radiobutton, Toplevel, messagebox, ttk
from tkinter import messagebox
from modules.project_database import TABLE_PROJECT_DATA
class ProjectEventWindow:
"""
Окно для работы с событиями: ввод текста, добавление в список,
управление записями и печать.
Принимает данные (код проекта) из окна выбора параметров.
"""
def __init__(self, manager, selected_data, root_window=None):
"""
:param selected_data: Словарь с данными из ProjectListWindow.
Должен содержать 'project_code', 'group_code', 'indicator_name', 'indicator_code'.
"""
self.manager = manager
self.root_window = root_window
# --- ВАЛИДАЦИЯ ВХОДНЫХ ДАННЫХ ---
project_code_from_data = selected_data.get("project_code")
group_code_from_data = selected_data.get("group_code")
indicator_name_from_data = selected_data.get("indicator_name")
indicator_code_from_data = selected_data.get("indicator_code")
if not all(
[
project_code_from_data,
group_code_from_data,
indicator_name_from_data,
indicator_code_from_data,
]
):
messagebox.showerror(
"Ошибка данных",
"Не удалось получить данные проекта, группы или показателя.",
)
return
self.selected_data = selected_data
self.project_code = project_code_from_data
self.group_code = group_code_from_data
self.indicator_name = indicator_name_from_data
self.indicator_code = indicator_code_from_data
# --- СОЗДАНИЕ ОКНА ---
# Используем Toplevel, если есть родительское окно, иначе создаем
# главное
if root_window:
self.window = Toplevel(root_window)
else:
self.window = tk.Tk()
project_name = self.selected_data.get("project_name", "Неизвестный проект")
self.window.title(f"Ввод событий - {project_name}")
self.window.geometry("750x600")
# --- КОРРЕКТНОЕ ЗАКРЫТИЕ ОКНА ---
# Эта строка связывает системную кнопку закрытия (крестик) с нашим
# методом
self.window.protocol("WM_DELETE_WINDOW", self.on_close)
# Создаем виджеты после того, как окно успешно создано и настроено
self._create_widgets()
def _create_widgets(self):
main_frame = tk.Frame(self.window)
main_frame.pack(padx=10, pady=2, fill="both", expand=True)
# --- Блок информации о контексте (Группа и Показатель) ---
info_frame = tk.Frame(main_frame)
info_frame.pack(fill="x", pady=5, padx=15)
context_text = f"Группа: {self.group_code}, Показатель: {self.indicator_name}"
tk.Label(info_frame, text=context_text, font=("Arial", 10)).pack(anchor="w")
# --- Блок ввода данных ---
date_frame = tk.Frame(main_frame)
date_frame.pack(fill="x", pady=3)
tk.Label(date_frame, text="Дата события:").pack(side="left")
self.date_entry = tk.Entry(date_frame, width=12)
self.date_entry.insert(0, "2026-05-08")
self.date_entry.pack(side="left", padx=(5, 0), ipadx=5)
# Радиокнопки "Направленность"
direction_frame = tk.Frame(main_frame)
direction_frame.pack(fill="x", pady=3, anchor="w")
tk.Label(direction_frame, text="Направленность:").pack(side="left")
radio_frame = tk.Frame(direction_frame)
radio_frame.pack(side="left", padx=(5, 0))
self.direction_var = tk.IntVar(value=1)
Radiobutton(
radio_frame, text="+ К цели", variable=self.direction_var, value=1
).pack(side="left", padx=(0, 10))
Radiobutton(
radio_frame, text="- От цели", variable=self.direction_var, value=-1
).pack(side="left", padx=(0, 10))
# Поле "Событие"
tk.Label(main_frame, text="Событие:").pack(anchor="w")
self.event_text = tk.Text(main_frame, height=4, wrap="word")
self.event_text.pack(fill="x", pady=(2, 5), padx=15)
# --- Блок кнопок управления ---
ctrl_btns = tk.Frame(main_frame)
ctrl_btns.pack(fill="x", pady=(0, 5), padx=15)
Button(
ctrl_btns, text="Вставить", command=self.paste_from_clipboard, width=15
).pack(side="left", padx=(0, 10))
Button(ctrl_btns, text="Добавить", width=15, command=self.add_entry).pack(
side="left", padx=(10, 10)
)
# Кнопка "Удалить"
Button(
ctrl_btns, text="Удалить", width=15, command=self.delete_selected_event
).pack(side="left", padx=(10, 10))
# Кнопка "Печать в Word" (пока заглушка)
Button(
ctrl_btns, text="Печать в Word", width=15, command=self.print_to_word
).pack(side="left", padx=(10, 10))
# --- Блок списка событий (ТАБЛИЦА) ---
events_main_frame = tk.Frame(main_frame)
events_main_frame.pack(fill="both", expand=True, pady=(5, 0), padx=15)
tk.Label(events_main_frame, text="Список событий:").pack(anchor="w")
# 1. Определяем колонки
columns = ("code", "date", "direction", "event")
# 2. Создаем Treeview (таблицу)
self.events_tree = ttk.Treeview(
events_main_frame, columns=columns, show="headings", height=10
)
# --- НОВАЯ СТРОКА: Привязка двойного клика ---
# Привязываем событие к конкретным элементам (строкам) таблицы.
# 'i' означает item (элемент/строка).
self.events_tree.bind("<Double-1>", self.on_edit_event)
# 3. Задаем ширину и заголовки (РЕШЕНИЕ ПРОБЛЕМЫ РАЗМЕРОВ)
self.events_tree.column("code", width=80, anchor="center", minwidth=60)
self.events_tree.column("date", width=90, anchor="center", minwidth=70)
self.events_tree.column("direction", width=45, anchor="center", minwidth=30)
self.events_tree.column("event", anchor="w", minwidth=200, stretch=tk.YES)
self.events_tree.heading("code", text="Код")
self.events_tree.heading("date", text="Дата")
self.events_tree.heading("direction", text="+/-")
self.events_tree.heading("event", text="Событие")
# 4. Добавляем скроллбар и связываем его с таблицей
scrollbar = tk.Scrollbar(
events_main_frame, orient="vertical", command=self.events_tree.yview
)
self.events_tree.configure(yscrollcommand=scrollbar.set)
# 5. Размещаем таблицу и скроллбар на экране
self.events_tree.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# --- Блок поиска по коду ---
code_search_frame = tk.Frame(main_frame)
code_search_frame.pack(fill="x", pady=(5, 0), padx=15)
tk.Label(code_search_frame, text="Код события:").grid(
row=0, column=0, sticky="e", padx=5
)
self.code_entry = tk.Entry(code_search_frame, width=15)
self.code_entry.grid(row=0, column=1, sticky="we", padx=5, pady=5)
search_btn = Button(
code_search_frame, text="Поиск", command=self.search_by_code
)
search_btn.grid(row=0, column=2, sticky="we", padx=5, pady=5)
def add_entry(self):
"""Обработка нажатия кнопки 'Добавить'."""
# 1. Получаем данные из полей ввода
event_text_content = self.event_text.get("1.0", tk.END).strip()
date_event = self.date_entry.get()
direction = self.direction_var.get()
# Проверяем, что поле "Событие" не пустое
if not event_text_content:
messagebox.showwarning("Ошибка", "Поле 'Событие' пустое.")
return
# 2. Подготавливаем путь к базе данных
db_folder = os.path.join(os.getcwd(), "project_dbs")
db_path = os.path.join(db_folder, f"{self.project_code}.db")
try:
# Создаем папку, если ее нет, и подключаемся к БД
os.makedirs(db_folder, exist_ok=True)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Проверяем и исправляем названия колонок, если нужно (для
# совместимости)
cursor.execute(f"PRAGMA table_info({TABLE_PROJECT_DATA})")
columns = [column[1] for column in cursor.fetchall()]
data_column = "event_data" if "event_data" in columns else "ivent_data"
code_column = "event_code" if "event_code" in columns else "ivent_code"
sign_column = "event_sign" if "event_sign" in columns else "ivent_sign"
# 3. Генерируем новый код события
cursor.execute(
f"""
SELECT COUNT(*)
FROM {TABLE_PROJECT_DATA}
WHERE project_code = ? AND indicator_code = ?
""",
(self.project_code, self.indicator_code),
)
count = cursor.fetchone()[0]
event_sequence_number = count + 1
event_code = f"{self.indicator_code}_{event_sequence_number}"
# 4. Вставляем данные в базу
cursor.execute(
f"""
INSERT INTO {TABLE_PROJECT_DATA} (
project_code, group_code, indicator_name, indicator_code,
{data_column}, {sign_column}, event_text_content, {code_column}, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
""",
(
self.project_code,
self.group_code,
self.indicator_name,
self.indicator_code,
date_event,
direction,
event_text_content,
event_code,
),
)
# 5. Сохраняем изменения в файл базы данных
conn.commit()
# 6. Обновляем интерфейс (ТАБЛИЦУ), если запись прошла успешно
if conn.total_changes > 0:
# Определяем текст для колонки "Направленность"
direction_str = "+" if direction == 1 else "-"
# Вставляем новую строку в таблицу Treeview
self.events_tree.insert(
"",
"end",
values=(
event_code, # Код
date_event, # Дата
direction_str, # Направленность
event_text_content, # Текст события
),
)
# Очищаем поле ввода текста
self.event_text.delete("1.0", tk.END)
# Показываем сообщение об успехе
messagebox.showinfo(
"Успех", f"Данные добавлены.\nКод события: {event_code}"
)
# Делаем окно "всегда поверх", чтобы оно не уходило на задний
# план
self.window.attributes("-topmost", 1)
self.window.after(100, lambda: self.window.attributes("-topmost", 0))
except Exception as e:
messagebox.showerror("Ошибка БД", f"Не удалось записать данные: {e}")
finally:
# Гарантируем, что соединение с базой данных будет закрыто
if "conn" in locals():
conn.close()
def load_events(self):
"""Загружает события из базы данных и отображает их в таблице."""
try:
for item in self.events_tree.get_children():
self.events_tree.delete(item)
db_path = os.path.join("project_dbs", f"{self.project_code}.db")
if not os.path.exists(db_path):
return
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute(
f"""
SELECT event_code, event_data, event_sign, event_text_content
FROM {TABLE_PROJECT_DATA}
WHERE project_code = ? AND group_code = ? AND indicator_name = ?
ORDER BY id DESC
""",
(self.project_code, self.group_code, self.indicator_name),
)
rows = cursor.fetchall()
if not rows:
return
for row in rows:
if row and len(row) >= 4:
event_code, event_date, sign_val, text_content = row
direction_str = "+" if sign_val == 1 else "-"
self.events_tree.insert(
"",
"end",
values=(event_code, event_date, direction_str, text_content),
)
except Exception as e:
pass # Или можно логировать ошибку в файл, если нужно
finally:
if "conn" in locals():
conn.close()
def paste_from_clipboard(self):
"""Вставляет текст из буфера обмена в поле события."""
try:
txt = self.window.clipboard_get()
self.event_text.insert(tk.INSERT, txt)
except Exception as e:
pass
def archive_project(self):
"""Обработка нажатия кнопки 'В архив'."""
messagebox.showinfo(
"Успех", f"Проект с кодом '{self.project_code}' архивирован."
)
def on_close(self):
"""Закрытие окна ввода событий и возврат в окно выбора."""
try:
# Если у нас есть родительское окно (окно выбора) и у него есть
# метод возврата
if self.root_window and hasattr(self.root_window, "show_selection_window"):
# Вызываем метод возврата в главном окне
self.root_window.show_selection_window()
# Затем уничтожаем текущее окно ввода событий
self.window.destroy()
except Exception as e:
print(f"[ДИАГНОСТИКА] Ошибка при закрытии окна: {e}")
def search_by_code(self):
"""
Выполняет поиск события по коду.
Если событие найдено:
1. Находит его группу и показатель.
2. Меняет текущую группу/показатель в окне.
3. Загружет список событий для новой группы.
4. Выделяет найденную строку.
"""
entered_code = self.code_entry.get().strip()
if not entered_code:
messagebox.showwarning("Поиск", "Введите код события.")
return
db_path = os.path.join("project_dbs", f"{self.project_code}.db")
if not os.path.exists(db_path):
messagebox.showerror("Ошибка", "База данных не найдена.")
return
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Запрос: ищем событие по коду и получаем его группу и показатель
cursor.execute(
f"""
SELECT group_code, indicator_name, indicator_code
FROM {TABLE_PROJECT_DATA}
WHERE project_code = ? AND event_code = ?
LIMIT 1
""",
(self.project_code, entered_code),
)
result = cursor.fetchone()
if not result:
messagebox.showinfo("Поиск", "Событие с таким кодом не найдено.")
return
# Получаем данные о том, где лежит это событие
found_group_code = result[0]
found_indicator_name = result[1]
found_indicator_code = result[2]
# Проверяем, не находимся ли мы уже в нужной группе/показателе
current_context = (self.group_code, self.indicator_name)
new_context = (found_group_code, found_indicator_name)
# Обновляем контекст окна ТОЛЬКО если он изменился
if current_context != new_context:
self.group_code = found_group_code
self.indicator_name = found_indicator_name
self.indicator_code = found_indicator_code
# Обновляем текст в информационном блоке вверху окна
context_text = (
f"Группа: {self.group_code}, Показатель: {self.indicator_name}"
)
# Находим виджет Label по его тексту в дочерних окнах и меняем текст
for child in self.window.winfo_children():
if isinstance(child, tk.Frame): # Ищем фрейм info_frame
for label in child.winfo_children():
if isinstance(label, tk.Label):
label.config(text=context_text)
break
# Перезагружаем список событий для новой группы/показателя,
# чтобы таблица обновилась
self.load_events()
# Теперь ищем строку с кодом в уже загруженной таблице
self._select_row_by_event_code(entered_code)
except Exception as e:
messagebox.showerror("Ошибка БД", f"Ошибка при поиске: {e}")
finally:
if "conn" in locals():
conn.close()
def _select_row_by_event_code(self, event_code_to_find):
"""
Вспомогательный метод.
Выделяет строку с указанным кодом в таблице и прокручивает к ней.
"""
children = self.events_tree.get_children()
if not children:
return
# Снимаем выделение со всех строк (если было)
self.events_tree.selection_remove(children)
# Ищем нужную строку по значению в первом столбце (Код)
for item in children:
code_val = self.events_tree.item(item, "values")[0]
if code_val == event_code_to_find:
# Выделяем строку (стандартный синий цвет Windows)
self.events_tree.selection_add(item)
# Прокручиваем таблицу так, чтобы строка стала видимой
self.events_tree.see(item)
# Устанавливаем фокус на таблицу (для стандартной подсветки)
self.events_tree.focus(item)
# Выходим из цикла, так как нашли нужное событие
break
def on_edit_event(self, event):
"""Обработчик двойного клика по строке в таблице."""
selected_item = self.events_tree.selection()
if not selected_item:
return
item_values = self.events_tree.item(selected_item[0], "values")
if not item_values:
return
event_code = item_values[0]
date_val = item_values[1]
direction_val = 1 if item_values[2] == "+" else -1
event_text = item_values[3]
# --- ИЗМЕНЕНИЕ ЗДЕСЬ ---
# Передаем 'self' как parent_window
edit_window = EventEditWindow(
self, # <-- Вот эта строчка! Передаем текущий объект как родителя
event_code,
date_val,
direction_val,
event_text,
)
def update_event_in_db(self, event_code, new_date, new_direction, new_event_text):
"""Обновляет событие в базе данных и таблице."""
db_path = os.path.join("project_dbs", f"{self.project_code}.db")
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute(
f"""
UPDATE {TABLE_PROJECT_DATA}
SET event_data = ?, event_sign = ?, event_text_content = ?
WHERE event_code = ? AND project_code = ? AND indicator_code = ?
""",
(
new_date,
new_direction,
new_event_text,
event_code,
self.project_code,
self.indicator_code,
),
)
conn.commit()
if conn.total_changes > 0:
selected_item = self.events_tree.selection()
if selected_item:
direction_str = "+" if new_direction == 1 else "-"
self.events_tree.item(
selected_item[0],
values=(event_code, new_date, direction_str, new_event_text),
)
messagebox.showinfo("Успех", "Изменения сохранены.")
else:
messagebox.showwarning("Нет изменений", "Данные не были изменены в БД.")
except Exception as e:
messagebox.showerror("Ошибка БД", f"Не удалось обновить данные: {e}")
finally:
if "conn" in locals():
conn.close()
def on_right_click(self, event):
"""Обработчик правого клика по строке в таблице."""
# Находим элемент под курсором
item = self.events_tree.identify_row(event.y)
if not item:
return
# Выделяем строку (если не выделена)
if item not in self.events_tree.selection():
self.events_tree.selection_set(item)
# Создаём контекстное меню с пунктом "Удалить"
menu = tk.Menu(self.window, tearoff=0)
menu.add_command(label="Удалить", command=lambda: self.delete_selected_event())
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
def delete_selected_event(self):
"""Удаляет выбранное событие из таблицы и базы данных."""
selected_items = self.events_tree.selection()
if not selected_items:
return
item_values = self.events_tree.item(selected_items[0], "values")
if not item_values:
return
event_code_to_delete = item_values[0]
# Убрал messagebox.askyesno для полной автоматизации (если это тест)
# Если нужно оставить диалог, верни код с messagebox обратно.
db_path = os.path.join("project_dbs", f"{self.project_code}.db")
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute(
f"""
DELETE FROM {TABLE_PROJECT_DATA}
WHERE event_code = ? AND project_code = ? AND indicator_code = ?
""",
(event_code_to_delete, self.project_code, self.indicator_code),
)
conn.commit()
if conn.total_changes > 0:
for item in selected_items:
self.events_tree.delete(item)
self.recalculate_event_codes()
except Exception as e:
pass # Или логирование ошибки
finally:
if "conn" in locals():
conn.close()
def print_to_word(self):
"""Создаёт и сохраняет файлы Excel и Word с отчётом."""
# --- 1. ПОДГОТОВИТЕЛЬНЫЙ ЭТАП ---
import os
from datetime import datetime
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root_dir = os.path.abspath(os.path.join(current_dir, ".."))
projects_dir = os.path.join(project_root_dir, "projects")
os.makedirs(projects_dir, exist_ok=True)
now = datetime.now()
timestamp = now.strftime("%Y%m%d_%H%M")
file_base_name = f"{self.project_code}_{timestamp}"
docx_file_name = f"{file_base_name}.docx"
docx_file_path = os.path.join(projects_dir, docx_file_name)
xlsx_file_name = f"{file_base_name}.xlsx"
xlsx_file_path = os.path.join(projects_dir, xlsx_file_name)
# --- 2. ПОЛУЧЕНИЕ ДАННЫХ ИЗ БАЗЫ (ГЛАВНОЕ ИЗМЕНЕНИЕ) ---
# Получаем данные для шапки (Название, Описание и т.д.)
project_name = "Неизвестный проект"
project_description = ""
date_opened = "[Дата открытия]"
responsible = "[ФИО]"
try:
import sqlite3
from tkinter import messagebox
import pandas as pd
db_path = os.path.join("project_dbs", f"{self.project_code}.db")
if not os.path.exists(db_path):
messagebox.showerror("Ошибка", "База данных не найдена.")
return
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Запрос для получения данных проекта для шапки
try:
cursor.execute(
f"""
SELECT name, description, date_opened, responsible
FROM {TABLE_PROJECT_DATA}
WHERE project_code = ?
LIMIT 1
""",
(self.project_code,),
)
header_row = cursor.fetchone()
# Если данные проекта найдены, используем их
if header_row:
project_name, project_description, date_opened, responsible = (
header_row
)
# Запрос для получения списка событий (как и раньше)
cursor.execute(
f"""
SELECT event_code, event_data, event_text_content
FROM {TABLE_PROJECT_DATA}
WHERE project_code = ?
ORDER BY group_code, indicator_code, event_data ASC
""",
(self.project_code,),
)
rows = cursor.fetchall()
except Exception as sql_error:
messagebox.showerror(
"Ошибка SQL", f"Ошибка при выполнении запроса к базе: {sql_error}"
)
return
finally:
conn.close()
if not rows:
messagebox.showwarning("Печать", "Нет событий для печати.")
return
valid_rows_data = []
for row in rows:
event_code, date_str, event_text = row
if event_text and date_str:
valid_rows_data.append((event_code, date_str, event_text))
if not valid_rows_data:
messagebox.showwarning(
"Печать",
"Нет валидных событий для печати (отсутствует дата или текст).",
)
return
except Exception as e:
messagebox.showerror(
"Ошибка подготовки", f"Не удалось подготовить данные: {e}"
)
return
# Флаги для финального сообщения
word_success = False
excel_success = False
# --- 3. СОЗДАНИЕ ВОРД ФАЙЛА ---
try:
from docx import Document
from docx.shared import Pt, Cm, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.section import WD_ORIENT
from docx.enum.table import WD_TABLE_ALIGNMENT, WD_TABLE_DIRECTION
doc = Document()
# Настройка страницы (Альбомная)
section = doc.sections[0]
section.orientation = WD_ORIENT.LANDSCAPE
section.page_width = Cm(29.7)
section.page_height = Cm(21.0)
section.top_margin = Cm(2)
section.bottom_margin = Cm(2)
section.left_margin = Cm(3)
section.right_margin = Cm(1.5)
# Настройка шрифта
style = doc.styles["Normal"]
font = style.font
font.name = "Times New Roman"
font.size = Pt(12)
# Шапка документа (ИСПРАВЛЕННАЯ ШАПКА)
doc.add_paragraph("Отчёт о событиях").alignment = WD_ALIGN_PARAGRAPH.CENTER
# Используем переменные с данными из базы
doc.add_paragraph(f"Название проекта: {project_name}")
doc.add_paragraph(f"Код проекта: {self.project_code}")
# Добавляем Описание (которого раньше не было)
doc.add_paragraph(f"Описание: {project_description}")
doc.add_paragraph(f"Дата открытия: {date_opened}")
doc.add_paragraph(f"Ответственный: {responsible}")
doc.add_paragraph("")
# --- СОЗДАНИЕ И ОФОРМЛЕНИЕ ТАБЛИЦЫ ---
# 1. Создаем таблицу (4 столбца, 1 строка для заголовка)
table = doc.add_table(rows=1, cols=4, style="Table Grid")
table.alignment = WD_TABLE_ALIGNMENT.CENTER # Исправлено на константу
table.allow_autofit = False
table.columns[0].width = Cm(2.0)
table.columns[1].width = Cm(2.5)
table.columns[2].width = Cm(2.5)
table.columns[3].width = Inches(7.0)
table.columns[3].width_rule = 3
# --- ЗАПОЛНЕНИЕ ТАБЛИЦЫ ---
hdr_cells = table.rows[0].cells
hdr_cells[0].text = "№ п/п"
hdr_cells[1].text = "Код"
hdr_cells[2].text = "Дата"
hdr_cells[3].text = "Событие"
# Выравнивание и жирный шрифт для заголовка + ЗАЛИВКА СЕРЫМ ЦВЕТОМ
for cell in hdr_cells:
p = cell.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.runs[0]
run.font.bold = True
try:
# Пробуем новый метод (для новых версий)
shading_elm = cell._tc.get_or_add_tcPr().get_or_add_shd()
shading_elm.fill = "D9D9D9"
shading_elm.val = "clear"
except AttributeError:
# Если не получилось (для старых версий), используем XML
from docx.oxml.ns import nsdecls
from docx.oxml import parse_xml
cell._tc.get_or_add_tcPr().append(
parse_xml(f'<w:shd {nsdecls("w")} w:fill="D9D9D9"/>')
)
for i, (code, date, text) in enumerate(valid_rows_data, start=1):
cells = table.add_row().cells
cells[0].text = str(i)
cells[1].text = code
cells[2].text = date
cells[3].text = text
for j in range(3):
cells[j].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
cells[3].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.LEFT
doc.save(docx_file_path)
word_success = True
except Exception as e:
messagebox.showerror("Ошибка Word", f"Не удалось создать Word-файл: {e}")
word_success = False
# --- 4. СОЗДАНИЕ ЭКСЕЛЬ ФАЙЛА ---
try:
data_for_excel = [
{"№ п/п": i, "Код": code, "Дата": date, "Событие": text}
for i, (code, date, text) in enumerate(valid_rows_data, start=1)
]
df = pd.DataFrame(data_for_excel)
df.to_excel(xlsx_file_path, index=False)
excel_success = True
except Exception as e:
messagebox.showerror("Ошибка Excel", f"Не удалось создать Excel-файл: {e}")
else:
pass
# --- 5. ФИНАЛЬНОЕ СООБЩЕНИЕ ПОЛЬЗОВАТЕЛЮ ---
if word_success or excel_success:
message_text = "Файлы успешно сохранены в папку 'projects':\n"
if word_success:
message_text += f"- {docx_file_name}\n"
if excel_success:
message_text += f"- {xlsx_file_name}\n"
messagebox.showinfo("Успех", message_text.strip())
class EventEditWindow:
"""
Окно для редактирования выбранного события.
"""
def __init__(self, parent_window, event_code, date_val, direction_val, event_text):
# Сохраняем ссылку на родительское окно и данные
self.parent_window = parent_window
self.event_code = event_code
self.date_val = date_val
self.direction_val = direction_val
# Создаем само окно
self.window = tk.Toplevel(parent_window.window)
self.window.title("Редактирование событий")
self.window.geometry("450x220")
self.window.transient(parent_window.window)
self.window.grab_set()
# --- 1. СОЗДАНИЕ ФРЕЙМА И НАСТРОЙКА СЕТКИ ---
# Используем один фрейм с сеткой для всех элементов.
# Это самый надежный способ избежать проблем с .pack() и .grid().
frame = tk.Frame(self.window, padx=10, pady=10)
frame.pack(fill="both", expand=True)
# Настраиваем колонки: вторая колонка (индекс 1) будет растягиваться.
frame.columnconfigure(1, weight=1)
# --- 2. РАЗМЕЩЕНИЕ ВИДЖЕТОВ В СЕТКЕ ---
# Поле "Дата" (Строка 0)
tk.Label(frame, text="Дата:").grid(row=0, column=0, sticky="e", pady=3)
self.date_entry = tk.Entry(frame, width=25)
self.date_entry.insert(0, date_val)
# sticky="we" заставляет поле ввода растягиваться по горизонтали.
self.date_entry.grid(row=0, column=1, sticky="we", pady=3)
# Поле "Направленность" (Строка 1)
tk.Label(frame, text="Направленность:").grid(row=1, column=0, sticky="e")
# Для радиокнопок создаем отдельный фрейм, чтобы они стояли в одну линию.
radio_frame = tk.Frame(frame)
radio_frame.grid(row=1, column=1, sticky="w", pady=3)
self.dir_var = tk.IntVar(value=direction_val)
Radiobutton(radio_frame, text="+ К цели", variable=self.dir_var, value=1).pack(
side="left"
)
Radiobutton(
radio_frame, text="- От цели", variable=self.dir_var, value=-1
).pack(side="left", padx=(10, 0))
# Поле "Событие" (Строка 2)
tk.Label(frame, text="Событие:").grid(
row=2, column=0, sticky="ne", pady=(10, 3)
)
self.event_text_field = tk.Text(frame, height=4, width=35, wrap="word")
self.event_text_field.insert("1.0", event_text)
# Используем sticky="we" здесь тоже для единообразия.
self.event_text_field.grid(
row=2, column=1, sticky="we", pady=(10, 3), padx=(0, 10)
)
# Кнопка "Сохранить" (Строка 3)
save_btn = tk.Button(
frame,
text="Сохранить изменения",
command=self.save_and_close,
bg="#4CAF50", # Зеленый цвет для красоты
fg="white",
)
# Размещаем кнопку на 3-й строке, растягиваем на обе колонки (columnspan=2)
save_btn.grid(row=3, columnspan=2, pady=(20, 5), ipadx=20)
def save_and_close(self):
"""
Сохраняет изменения из полей ввода,
передает их в родительское окно и закрывает это окно.
"""
# 1. Получаем новые значения из интерфейса
new_date = self.date_entry.get()
new_direction = self.dir_var.get()
# Получаем текст из поля. "end-1c" - правильный способ избежать лишней пустой строки.
new_event_text = self.event_text_field.get("1.0", tk.END).strip()
# 2. Проверка: не пустое ли поле "Событие"?
if not new_event_text:
messagebox.showwarning("Ошибка", 'Поле "Событие" не может быть пустым.')
return # Выходим из функции, ничего не сохраняя
# 3. Передаем данные в родительское окно для обновления БД и таблицы
# self.parent_window - это ссылка на ProjectEventWindow, которую мы передали в __init__
self.parent_window.update_event_in_db(
self.event_code, # Код события, которое мы редактируем
new_date, # Новая дата
new_direction, # Новое направление (+1 или -1)
new_event_text, # Новый текст
)
# 4. Закрываем окно редактирования
self.window.destroy()
def on_close(self):
"""Закрытие окна ввода событий.
Теперь просто уничтожаем окно, не пытаясь ничего вернуть,
так как за нас это сделал open_event_window_from_list."""
try:
# Просто закрываем текущее окно.
# Логика возврата перенесена в main.py и выполняется там
# через уничтожение и создание новых окон.
self.window.destroy()
except Exception as e:
print(f"[ДИАГНОСТИКА] Ошибка при закрытии окна: {e}")
# ==========================================================
# ==========================================================
# -*- coding: utf-8 -*-
# project_database.py
# ==========================================================
import os
import sqlite3
from pathlib import Path
from typing import Optional
# Константы для имен таблиц. Это удобно для рефакторинга.
TABLE_PROJECT_DATA = "project_data"
TABLE_EVENT_LOGS = "event_logs"
class ProjectDatabase:
"""
Класс для управления базой данных проекта.
Создает файл БД с именем на основе кода проекта в указанной директории.
"""
def __init__(self, project_code: str, base_dir: str = "project_dbs"):
"""
Инициализирует объект для работы с БД проекта.
:param project_code: Уникальный код проекта (например, 'PRJ-001').
:param base_dir: Директория, где будут храниться файлы баз данных.
"""
# --- ГЛАВНОЕ ИСПРАВЛЕНИЕ РЕГИСТРА ---
# Сначала очищаем имя от опасных символов, затем ПРИНУДИТЕЛЬНО делаем маленьким
clean_code = self._sanitize_filename(project_code)
self.project_code = clean_code.lower()
self.base_dir = base_dir
self.db_path = Path(base_dir) / f"{self.project_code}.db"
# Создаем директорию, если ее нет.
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.connection: Optional[sqlite3.Connection] = None
@staticmethod
def _sanitize_filename(name: str) -> str:
"""Очищает строку от символов, недопустимых в именах файлов."""
return "".join(c for c in name if c.isalnum() or c in "_-.").rstrip()
def connect(self) -> sqlite3.Connection:
"""Устанавливает соединение с базой данных. Создает файл, если его нет."""
if self.connection is None:
try:
self.connection = sqlite3.connect(self.db_path)
except sqlite3.Error as e:
raise RuntimeError(f"Ошибка подключения к БД {self.db_path}: {e}")
return self.connection
def close(self):
"""Закрывает соединение с базой данных."""
if self.connection:
self.connection.close()
self.connection = None
def create_tables(self):
"""
Создает все необходимые таблицы и обновляет их структуру.
"""
conn = self.connect()
cursor = conn.cursor()
# 1. Таблица логов событий
cursor.execute(f"""CREATE TABLE IF NOT EXISTS {TABLE_EVENT_LOGS} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_code TEXT NOT NULL,
event_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
event_text TEXT NOT NULL
)""")
# 2. Основная таблица проекта
cursor.execute(f"""CREATE TABLE IF NOT EXISTS {TABLE_PROJECT_DATA} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_code TEXT NOT NULL UNIQUE,
name TEXT,
created_at TEXT NOT NULL,
description TEXT DEFAULT '',
date_opened TEXT DEFAULT '',
responsible TEXT DEFAULT '',
group_name TEXT,
group_kov REAL,
indicator_name TEXT,
indicator_code TEXT,
-- Новые поля единого формата Event
event_data DATE,
event_sign INTEGER,
event_code TEXT,
event_text_content TEXT,
group_code TEXT
)""")
# 3. МИГРАЦИЯ: Удаляем старые колонки ivent_*, если они еще остались
# Мы НЕ добавляем новые перед проверкой, мы просто пытаемся удалить старые.
# Если их нет — SQLite выдаст ошибку, которую мы игнорируем через IF EXISTS.
columns_to_drop = ["ivent_data", "ivent_sign", "ivent_code"]
for col_name in columns_to_drop:
try:
cursor.execute(f"ALTER TABLE {TABLE_PROJECT_DATA} DROP COLUMN {col_name}")
print(f"[РЕФАКТОРИНГ] Удалена старая колонка '{col_name}'.")
except sqlite3.OperationalError:
# Колонки нет — всё хорошо, идем дальше
pass
conn.commit()
def delete_database_file(self):
"""
Полностью удаляет файл базы данных с диска.
Используется при завершении или архивации проекта.
"""
self.close()
if self.db_path.exists():
try:
self.db_path.unlink()
except OSError as e:
print(f"Ошибка при удалении файла БД: {e}")
# ===========================================================
Это был блок старого кода, который необходимо использовать как коды-донары.
Ниже описываю что должно получиться в итоге.
Имеется главный интерфейсный файл. Он фактически является витриной с кнопками, откуда осуществляется вход в аккордеон этого раздела (dukora\gui\main_interface.py). Сам аккордеон разработан и находится в соответствующей папке, где в дальнейшем должны лежать остальные файлы для этого раздела (dukora\modules\project\project_window.py). На данный момент в аккордеоне стоят заглушки. Твоя задача проанализировать старые файлы, которые были сделаны на ткинкере и заполнить аккордеон по новому на кастомткинкере, создав новые файлы. Есть условие - Один заголовок аккордеона это 2 новых файла: один для интерфейса, второй для функционала. Все файлы должны начинаться с префикса project_.
Дальше описываю структуру аккордеона.
1. Заголовок окна - Наполнение проекта - остается без изменений.
2. Ниже заголовка идет блок Выбрать проект с выпадающим списком. Список необходимо подключить к базе проектов. Блок остается на своем месте, блок будет глобальным и действовать для всех ниже заголовков.
3. Заголовок аккордеона Создать проект. В открывшемся аккордеоне должны быть:
- Название (проекта): и рядом поле для написания нормального большого заголовка проекта на человеческом языке.
- Шифр (проекта): и рядом поле для написания краткого названия проекта.
- Код (проекта): и рядом поле для написания кода, он же идентификатор.
- Дата открытия (проекта): и рядом поле.
- Ответственный: и поле для написания ответственного лица.
- Кнопка Редактировать. После нажатия на кнопку Редактировать Пользователь должен иметь доступ ко всем полям шапки для редактирования кроме поля с Кодом проекта. Вместо кнопки Редактировать должна появиться кнопка Сохранить - для выхода из режима редактирования и опять появиться кнопка Редактировать.Кнопка Редактировать - зеленого цвета, кнопка Сохранить - коричневого цвета.
4. Заголовок аккордеона Открыть для ввода событий.
- Начинается поле этого окна с блока Выбрать группу: и выпадающий список. Этот блок остается, только его необходимо подключить к базе.
- Ниже создать аналогичный блок Выбрать показатель: и создать выпадающий список. Это список показателей из выбранной группы.Так как показатели будут очень длинными по содержанию сделать так, чтобы при наведении мышки на показатель всплывал его полный текст. Как только смещается мышка эта подсказка исчезает через 1 секунду.
- Содержание события: ниже этого подзаголовка поле на всю ширину окна в 2-3 строки для вставки текста. Текст должен вставляться как кнопкой Вставить (расположить ниже поля с правой строны черного цвета) так и при клике правой кнопкой мыши в поле.
- Дата события: и рядом русско-язычный календарьдля выбора и поле с датой.
Направленность события: и рядом две радиокнопки: напротив первой написать в скобках (+), она по умолчанию нажата и действует положительный знак, напротив второй написать в скобках (-).
- Три кнопки Добавить, Редактировать, Удалить.
Добавить - для добавления в базу данных События.
Редактировать - для перехода в режим редактирования. В этот момент она меняется на кнопку Сохранить.
Удалить - для удаления выбранного события.
- Ниже кнопок поле с имеющимися событиями. В него попадают только события выбранной группы и выбранного показателя, либо оно остается пустым. При клике на событии оно выделяется и его можно либо Редактировать, либо Удалить.
- Цвет кнопок:
Добавить - синяя
Редактировать - зеленая
Сохранить - коричневая
Удалить - красная.
- Таблицу организовать следующим образом:
Поля - №п/п (сквозной порядок, для красоты в таблице), Код, Дата, (+ -)(показывает знак), Событие.
Событие должно быть в одну строчку. Так как оно не вмещается в размер колонки текст должен как бы обрываться. Чтобы его увидеть полностью - кликнуть по сообщению и оно всплывет рядом. Достаточно показать 3 строчки событий. Последняя строчка таблицы всегда должна быть на виду.
- Ниже таблицы расположить блок поиска событий по всей базе данного проекта по коду события. После того как событие будет найдено - на экране происходит переключение в выпадающих списках в соответствующую группу , в соответствующий показатель, в таблице подсвечивается искомое событие.
Блок - Наберите код: поле для набора кода и кнопка синего цвета Искать.
5. Заголовки Показать графики и Настройка печати пока не трогаем и оставляем в режиме как есть.
6. Базы
Новый файл project_database.ру расположить по адресу dukora\modules\data.
Файлы с создающимися базами проекта (один проект - одна база) распологать со всеми базами по адресу dukora\db
Ниже даю файлы для примера организации таблицы, всплывающих показателей/событий, меняющихся кнопок Редактировать/Сохранить, организации путей к модулям.
# =====================================
#================================================
# dukora/modules/utils/resources.py
#================================================
import sys
import os
from pathlib import Path
def get_project_root() -> Path:
"""
Определяет корень проекта один раз.
Работает и при запуске .py, и внутри замороженного .exe.
"""
if getattr(sys, 'frozen', False):
# Если приложение запущено как bundle (PyInstaller)
return Path(sys.executable).parent
else:
# Если запущено как скрипт
return Path(__file__).resolve().parents[2]
def resource_path(relative_path: str) -> Path:
try:
# Режим работы внутри скомпилированного EXE-файла
base_path = Path(sys._MEIPASS)
except Exception:
# Режим разработки (.py): Идем строго от этого файла (__file__)
# Поднимаемся на 2 уровня вверх до корня проекта 'dukora'
base_path = Path(__file__).resolve().parents[2]
return base_path / relative_path
# =====================================
# =====================================
# dukora\modules\manager
# =====================================
import customtkinter as ctk
from tkinter import ttk, messagebox, filedialog
import tkinter as tk # Импортируем базовый tkinter для Text виджета
import pyperclip
from docx import Document
from modules.manager.manager_metrics_work import (
get_all_indicators_by_group, insert_indicator, delete_indicator_by_code,
IndicatorDTO, generate_unique_code, save_docx_template, update_indicator_name
)
from modules.manager.manager_group_work import list_groups as group_list
class ManagerMetricsPanel(ctk.CTkFrame):
def __init__(self, master):
super().__init__(master)
self.parent_main = master
self.group_names_to_codes = {}
self._current_editing_code = None
self._active_tooltip = None
# === СЕТКА ОКНА ===
self.grid_columnconfigure(0, weight=0)
self.grid_columnconfigure(1, weight=0)
self.grid_rowconfigure(0, weight=0)
self.grid_rowconfigure(1, weight=0)
self.grid_rowconfigure(2, weight=0)
self.grid_rowconfigure(3, weight=1)
self.grid_rowconfigure(4, weight=0)
# === СТРОКА 0: ВЫБОР ГРУППЫ ===
ctk.CTkLabel(self, text="Группа:").grid(row=0, column=0, sticky="w", padx=(10, 5), pady=(10, 0))
self.group_combobox = ctk.CTkComboBox(self, state="readonly", command=self._on_group_selected)
self.group_combobox.grid(row=0, column=0, columnspan=2, sticky="ew", padx=10, pady=(10, 0))
# === СТРОКА 1: КОНТЕЙНЕР ДЛЯ ПОЛЕЙ ===
input_frame = ctk.CTkFrame(self)
# Прижимаем к верху (Группа) и низу (Кнопки), убрав лишние отступы
input_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 5))
input_frame.grid_columnconfigure(0, weight=1)
# Вторая колонка больше не нужна для выравнивания поля, но может понадобиться для кнопки
input_frame.grid_columnconfigure(1, weight=0)
# Метка "Название"
ctk.CTkLabel(input_frame, text="Название показателя:", anchor="w").grid(row=0, column=0, sticky="w")
# ЕДИНСТВЕННОЕ ОСТАВШЕЕСЯ ПОЛЕ (многострочное)
self.name_entry = ctk.CTkTextbox(
input_frame, height=60, wrap="word", border_width=1
)
# Теперь это первая строка с контентом внутри фрейма (row=1)
self.name_entry.grid(row=1, column=0, sticky="ew", padx=(0, 5), pady=(2, 2))
# --- ЭТА ЧАСТЬ УДАЛЕНА ---
# self.code_entry = ctk.CTkEntry(input_frame, height=28)
# self.code_entry.grid(row=2, column=0, sticky="ew", padx=(0, 5), pady=(2, 5))
# -------------------------
# Привязки для Textbox остаются прежними
self.name_entry.bind("<Button-3>", self._paste_via_pyperclip)
self.name_entry.bind("<Control-v>", lambda e: (self._paste_via_pyperclip(e), "break"))
self.name_entry.bind("<Shift-Insert>", lambda e: (self._paste_via_pyperclip(e), "break"))
self.name_entry.bind("<Escape>", lambda e: self._reset_indicator_edit_mode())
# Кнопка вставки. Так как текстбокс теперь один, логичнее перенести кнопку на row=2,
# чтобы она визуально стояла под полем ввода, а не сбоку от него по центру высоты.
paste_btn = ctk.CTkButton(input_frame, text="Вставить", fg_color="#2B2B2B", hover_color="#3C3C3C", command=self._paste_via_pyperclip)
paste_btn.grid(row=2, column=0, sticky="e", padx=(0, 5), pady=(2, 5), ipadx=10)
# Если кнопка должна быть строго справа, используем две колонки:
# paste_btn.grid(row=1, column=1, sticky="nsew", padx=(5, 0), pady=5, ipadx=10)
# === СТРОКА 2: КНОПКИ УПРАВЛЕНИЯ ===
action_frame = ctk.CTkFrame(self)
action_frame.grid(row=2, column=0, columnspan=2, sticky="ew", padx=10, pady=(0, 0))
for i in range(4):
action_frame.grid_columnconfigure(i, weight=1, uniform="actions")
self.add_btn = ctk.CTkButton(action_frame, text="Добавить", fg_color="#3B82F6", command=self.add_indicator)
self.add_btn.grid(row=0, column=0, sticky="ew", padx=2, pady=2)
self.edit_btn = ctk.CTkButton(action_frame, text="Редактировать", fg_color="#10B981", command=self.edit_indicator_select)
self.edit_btn.grid(row=0, column=1, sticky="ew", padx=2, pady=2)
self.delete_btn = ctk.CTkButton(action_frame, text="Удалить", fg_color="#EF4444", command=self.delete_indicator)
self.delete_btn.grid(row=0, column=2, sticky="ew", padx=2, pady=2)
self.save_btn = ctk.CTkButton(action_frame, text="Сохранить", state="disabled", fg_color="#8B4513", hover_color="#A0522D", command=self.update_indicator_name)
self.cancel_edit_btn = ctk.CTkButton(action_frame, text="Отмена", fg_color="#6B7280", hover_color="#4B5563", command=self._reset_indicator_edit_mode)
# === СТРОКА 3: ТАБЛИЦА ДАННЫХ ===
tree_container = ctk.CTkFrame(self, fg_color="transparent")
tree_container.grid(row=3, column=0, columnspan=2, sticky="nsew", padx=10, pady=0)
tree_container.grid_columnconfigure(0, weight=1); tree_container.grid_rowconfigure(0, weight=1)
style = ttk.Style()
try:
theme_name = "clam" if "dark" in ctk.get_appearance_mode() else "default"
style.theme_use(theme_name)
style.configure("Custom.Treeview", background="#2b2b2b", fieldbackground="#2b2b2b", foreground="white", rowheight=30)
style.configure("Custom.Treeview.Heading", background="#34495e", foreground="white")
style.map("Custom.Treeview", background=[("selected", "#3c5a72")])
except Exception: pass
columns = ("number", "name", "code")
self.indicators_tree = ttk.Treeview(tree_container, columns=columns, show="headings", selectmode="browse", style="Custom.Treeview")
self.indicators_tree.grid(row=0, column=0, sticky="nsew")
scrollbar = ctk.CTkScrollbar(tree_container, orientation="vertical", command=self.indicators_tree.yview)
scrollbar.grid(row=0, column=1, sticky="ns", padx=(0, 2))
self.indicators_tree.configure(yscrollcommand=scrollbar.set)
self.indicators_tree.heading("number", text="№"); self.indicators_tree.column("number", width=50, anchor="center", stretch=False)
self.indicators_tree.heading("name", text="Название");
self.indicators_tree.column("name", width=0, minwidth=250, anchor="w", stretch=True)
self.indicators_tree.heading("code", text="Код"); self.indicators_tree.column("code", width=110, anchor="center", stretch=False)
self.indicators_tree.bind("<Button-1>", self._on_tree_click, add="+")
# === СТРОКА 4: ЭКСПОРТ ===
bottom_frame = ctk.CTkFrame(self)
bottom_frame.grid(row=4, column=0, columnspan=2, sticky="e", padx=10, pady=(0, 10))
ctk.CTkButton(bottom_frame, text="Экспорт в WORD", fg_color="#2B2B2B", hover_color="#3C3C3C", command=self.print_to_docx).pack(side="right", padx=5)
self.bind("<FocusOut>", self._on_window_focus_out)
self.update_group_combobox()
self.update_indicators_list()
self.after(100, lambda: self.name_entry.focus_set())
# --- МЕТОДЫ ПОДДЕРЖКИ ---
def refresh_groups_ui(self):
"""Публичный метод для принудительной перерисовки списка групп"""
self.update_group_combobox()
def _on_group_selected(self, event=None):
"""
Вызывается каждый раз, когда пользователь выбирает пункт в выпадающем списке.
Принудительно очищает таблицу и загружает актуальные данные.
"""
self.update_indicators_list()
def _paste_via_pyperclip(self, event=None):
try:
clipboard_text = pyperclip.paste()
if not clipboard_text:
return "break"
# Удаляем выделенный текст, если он есть
try:
self.name_entry.delete("sel.first", "sel.last")
except tk.TclError:
pass
# Вставляем содержимое буфера
self.name_entry.insert("insert", clipboard_text)
# Возвращаем фокус и ставим курсор в конец текста
self.name_entry.focus_set()
self.name_entry.mark_set("insert", "end")
except Exception as e:
print(f"[CLIPBOARD] Error: {e}")
return "break"
def update_group_combobox(self):
groups = group_list(); self.group_names_to_codes.clear()
for g in groups: self.group_names_to_codes[g.name] = getattr(g, 'code', '')
names = [g.name for g in groups]; self.group_combobox.configure(values=names)
if names: self.group_combobox.set(names[0]); self.update_indicators_list()
def update_indicators_list(self, event=None):
selected_group_code = self.group_names_to_codes.get(self.group_combobox.get())
indicators = get_all_indicators_by_group(selected_group_code) if selected_group_code else []
for item in self.indicators_tree.get_children(): self.indicators_tree.delete(item)
last_iid = None
for idx, ind in enumerate(indicators, start=1):
val_name = getattr(ind, 'name', '--')
val_code = getattr(ind, 'code', '--')
last_iid = val_code
self.indicators_tree.insert("", "end", iid=val_code, values=(f"{idx}.", val_name, val_code))
# Прокрутка к последнему добавленному элементу (Задача 4)
if last_iid:
self.indicators_tree.see(last_iid)
# --- ЛОГИКА КНОПОК И ИНТЕРАКТИВА ---
def add_indicator(self):
group_code = self.group_names_to_codes.get(self.group_combobox.get())
indicator_name = self.name_entry.get("1.0", "end").rstrip("\n").strip()
if not group_code or not indicator_name: messagebox.showwarning("Ошибка", "Выберите группу и введите название!"); return
new_code = generate_unique_code(group_code)
try:
insert_indicator(new_code, indicator_name, group_code)
messagebox.showinfo("Готово", "Индикатор добавлен."); self.update_indicators_list(); self.name_entry.delete("1.0", "end")
except Exception as e: messagebox.showerror("Ошибка", str(e))
def edit_indicator_select(self):
selected = self.indicators_tree.selection()
if not selected:
messagebox.showwarning("Ошибка", "Выберите показатель в таблице."); return
code = selected[0]
item = self.indicators_tree.item(code)
current_name = item["values"][1].replace('\n', ' ')
self.name_entry.delete("1.0", "end")
self.name_entry.insert("1.0", current_name)
# Используем правильное имя атрибута
self._current_editing_code = code
# Скрываем стандартные кнопки
self.edit_btn.grid_remove()
self.delete_btn.grid_remove()
# Показываем новые кнопки В ТЕ ЖЕ КОЛОНКИ (col 1 и col 2)
self.save_btn.grid(row=0, column=1, sticky="ew", padx=2, pady=2)
self.cancel_edit_btn.grid(row=0, column=2, sticky="ew", padx=2, pady=2)
self.save_btn.configure(state="normal")
def update_indicator_name(self):
# Исправлено с self._current_editing на self._current_editing_code
if not getattr(self, '_current_editing_code', None):
messagebox.showwarning("Ошибка", "Сначала выберите элемент через 'Редактировать'."); return
new_name = self.name_entry.get("1.0", "end").rstrip("\n").strip()
if not new_name:
messagebox.showwarning("Ошибка", "Название не может быть пустым."); return
success = update_indicator_name(self._current_editing_code, new_name)
if success:
messagebox.showinfo("Готово", "Название обновлено.")
self.update_indicators_list()
self._reset_indicator_edit_mode()
else:
messagebox.showerror("Ошибка БД", "Не удалось обновить данные.")
def _reset_indicator_edit_mode(self):
# Проверяем наличие правильного атрибута
if hasattr(self, '_current_editing_code'):
delattr(self, '_current_editing_code')
# Для CTkTextBox используем delete без координат
self.name_entry.delete("1.0", "end")
# --- ЭТО УДАЛЕНО ---
# self.code_entry.delete(0, "end")
# self.code_entry.configure(state="normal")
# -------------------
self.save_btn.configure(state="disabled")
self.save_btn.grid_remove()
self.cancel_edit_btn.grid_remove()
# Возвращаем кнопки на место. Обратите внимание: колонки grid_columnconfigure(action_frame) идут 0, 1, 2, 3.
# add_btn сидит в 0, значит edit_btn должен сидеть в 1, а delete_btn в 2.
self.edit_btn.grid(row=0, column=1, sticky="ew", padx=2, pady=2)
self.delete_btn.grid(row=0, column=2, sticky="ew", padx=2, pady=2)
self.indicators_tree.focus_set()
def delete_indicator(self):
selected = self.indicators_tree.selection()
if not selected: messagebox.showwarning("Ошибка", "Выберите показатель."); return
code = selected[0]
if messagebox.askyesno("Подтверждение", f"Удалить этот показатель?"):
try:
delete_indicator_by_code(code)
messagebox.showinfo("Готово", "Удалено."); self.update_indicators_list(); self.name_entry.delete("1.0", "end")
except Exception as e: messagebox.showerror("Ошибка", str(e))
def _on_tree_click(self, event):
region = self.indicators_tree.identify_region(event.x, event.y)
if region == "cell":
column_id = self.indicators_tree.identify_column(event.x)
if column_id == "#2":
item_id = self.indicators_tree.identify_row(event.y)
if item_id:
full_text = self.indicators_tree.item(item_id)['values'][1]
# Задача 3: Если тултип уже открыт — убиваем его перед созданием нового
if self._active_tooltip and self._active_tooltip.winfo_exists():
self._active_tooltip.destroy()
tooltip = ctk.CTkToplevel(self)
self._active_tooltip = tooltip
tooltip.attributes("-topmost", True)
tooltip.overrideredirect(True)
x = self.winfo_rootx() + event.x + 15
y = self.winfo_rooty() + event.y + 15
tooltip.geometry(f"+{x}+{y}")
label = ctk.CTkLabel(tooltip, text=full_text, wraplength=400, justify="left")
label.pack(padx=10, pady=10)
def on_leave(_):
if self._active_tooltip and self._active_tooltip.winfo_exists():
self._active_tooltip.destroy()
self._active_tooltip = None
tooltip.bind("<Leave>", on_leave)
def keep_open(_): return "break"
tooltip.bind("<Button-1>", keep_open)
def print_to_docx(self):
group_name = self.group_combobox.get()
if not group_name: messagebox.showwarning("Ошибка", "Выберите группу."); return
group_code = self.group_names_to_codes.get(group_name)
indicators = get_all_indicators_by_group(group_code)
if not indicators: messagebox.showwarning("Ошибка", "Нет данных для экспорта."); return
from datetime import date
filename = f"{group_name.replace(' ', '_')}_{date.today().strftime('%Y-%m-%d')}.docx"
path = filedialog.asksaveasfilename(initialfile=filename, defaultextension=".docx", filetypes=[("Word files", "*.docx"), ("All files", "*.*")])
if not path: return
success = save_docx_template(group_name, indicators, path)
if success: messagebox.showinfo("Успех", f"Сохранено:\n{path}")
else: messagebox.showerror("Ошибка записи", "Проверьте консоль или права доступа к папке.")
def _on_window_focus_out(self, event):
"""Закрывает всплывающую подсказку, если фокус ушел из основного окна."""
if self._active_tooltip and self._active_tooltip.winfo_exists():
self._active_tooltip.destroy()
self._active_tooltip = None
def on_close(self):
# Чистим за собой при закрытии панели
if self._active_tooltip and self._active_tooltip.winfo_exists():
self._active_tooltip.destroy()
self.destroy()
# ===========================================================
# dukora/modules/manager/manager_metrics_work.py
import os
import sqlite3
from typing import List, Any
from contextlib import contextmanager
from modules.data.database_module import get_db_connection
from typing import List, Any
class IndicatorDTO:
def __init__(self, id: int, code: str, name: str, group_code: str):
self.id = id
self.code = code
self.name = name
self.group_code = group_code
@contextmanager
def _get_cursor():
conn = get_db_connection()
try:
cursor = conn.cursor()
yield cursor
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def insert_indicator(code: str, name: str, group_code: str) -> IndicatorDTO:
print(f"[WORK LOGIC] INSERT | Код: {code}, Имя: {name}")
with _get_cursor() as cursor:
cursor.execute('''
CREATE TABLE IF NOT EXISTS indicators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
code TEXT NOT NULL UNIQUE,
group_code TEXT NOT NULL,
FOREIGN KEY (group_code) REFERENCES groups(code) ON DELETE CASCADE
)
''')
cursor.execute("INSERT INTO indicators (code, name, group_code) VALUES (?, ?, ?)",
(code, name, group_code))
new_id = cursor.lastrowid
return IndicatorDTO(id=new_id, code=code, name=name, group_code=group_code)
def delete_indicator_by_code(code: str):
print(f"[WORK LOGIC] DELETE | Код: {code}")
with _get_cursor() as cursor:
cursor.execute("DELETE FROM indicators WHERE code = ?", (code,))
def get_all_indicators_by_group(group_code: str) -> List[IndicatorDTO]:
print(f"[WORK LOGIC] SELECT | Группа: {group_code}")
with _get_cursor() as cursor:
cursor.execute("SELECT id, code, name, group_code FROM indicators WHERE group_code = ?", (group_code,))
rows = cursor.fetchall()
return [IndicatorDTO(id=row[0], code=row[1], name=row[2], group_code=row[3]) for row in rows]
# === ИСПРАВЛЕННАЯ ФУНКЦИЯ ОБНОВЛЕНИЯ ДЛЯ SQLITE ===
def update_indicator_name(code: str, new_name: str) -> bool:
"""
Обновляет название индикатора по его уникальному коду через SQLite.
"""
try:
with _get_cursor() as cursor:
# Сначала проверяем, существует ли такой индикатор
cursor.execute("SELECT name FROM indicators WHERE code = ?", (code,))
result = cursor.fetchone()
if not result:
print(f"[DB] Индикатор с кодом {code} не найден.")
return False
old_name = result[0]
# Выполняем обновление
cursor.execute("UPDATE indicators SET name = ? WHERE code = ?", (new_name.strip(), code))
# commit произойдет автоматически благодаря @contextmanager
print(f"[DB] Название обновлено: '{old_name}' -> '{new_name.strip()}'")
return True
except Exception as e:
# Rollback сработает автоматически благодаря декораторам контекста
print(f"[DB FATAL] Ошибка при обновлении индикатора: {repr(e)}")
return False
def generate_unique_code(group_code: str) -> str:
"""
Генерирует уникальный код показателя внутри группы по шаблону ГРУППА-N.
Например: Эко-1, Эко-2, ИТ-1.
"""
with _get_cursor() as cursor:
cursor.execute("""
SELECT MAX(CAST(substr(code, instr(code, '-') + 1) AS INTEGER))
FROM indicators WHERE code LIKE ?
""", (f"{group_code}-%",))
row = cursor.fetchone()[0]
if row is None:
next_num = 1
else:
next_num = row + 1
return f"{group_code}-{next_num}"
def save_docx_template(group_name: str, indicators: List[Any], path: str) -> bool:
if not indicators:
return False
try:
from docx import Document
from docx.shared import Cm, Pt
from docx.enum.section import WD_ORIENTATION
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
doc = Document()
# --- СТРАНИЦА ---
section = doc.sections[0]
section.orientation = WD_ORIENTATION.LANDSCAPE
section.page_width = Cm(29.7)
section.page_height = Cm(21.0)
section.left_margin = Cm(3.0)
section.right_margin = Cm(2.0)
section.top_margin = Cm(2.0)
section.bottom_margin = Cm(2.0)
# --- СТИЛЬ ---
style = doc.styles['Normal']
font = style.font
font.name = 'Times New Roman'
font.size = Pt(12)
# --- ЗАГОЛОВОК ---
p = doc.add_paragraph(f'Показатели группы: {group_name}')
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.runs[0].font.bold = True
p.runs[0].font.size = Pt(14)
doc.add_paragraph(' ')
# --- ТАБЛИЦА ---
table = doc.add_table(rows=len(indicators) + 1, cols=3)
table.style = 'Table Grid'
table.autofit = False
table.alignment = WD_TABLE_ALIGNMENT.CENTER
col_widths_cm = [2.0, 2.0, 20.5]
for i in range(3):
for cell in table.columns[i].cells:
cell.width = Cm(col_widths_cm[i])
# Шапка
hdr_cells = table.rows[0].cells
titles = ['№ п/п', 'Код', 'Наименование']
for i in range(3):
hdr_cells[i].text = titles[i]
para = hdr_cells[i].paragraphs[0]
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = para.runs[0]
run.bold = True
run.font.size = Pt(14)
run.font.name = 'Times New Roman'
# Данные
for idx, ind in enumerate(indicators, start=1):
row_cells = table.rows[idx].cells
for i, cell in enumerate(row_cells):
cell.width = Cm(col_widths_cm[i])
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.TOP
row_cells[0].text = str(idx)
row_cells[1].text = ind.code
row_cells[2].text = ind.name.replace('\\n', '\n')
for cell in row_cells:
for para in cell.paragraphs:
for run in para.runs:
run.font.name = 'Times New Roman'
run.font.size = Pt(12)
doc.save(path)
return True
except Exception as e:
print(f"[WORK LOGIC FATAL] Ошибка: {repr(e)}")
return False
# =====================================
План работы:
1. Ты читаешь все, анализируешь.
2. Что не понятно - уточняешь по одному вопросу - вопрос-ответ-вопрос....
3. Даешь название нового файла, код - я создаю файл, вставляю код.
4. Создаем файлы рефакторинга для 2х заголовков аккордеона и после этого начинаем тестировать и править ошибки.
5. Я вайб-кодер, поэтому работаем по принципу - Ты программист, я получатель кода , замены и вставки делаем целиком: или методами, или файлами, или от комментария до комментария. Никаких кусков.