Загрузка данных
Работаем с окном "Наполнение проекта" с заголовком аккордеона "Построить диаграмму".
Цель: Заставить окно графика открыться.
Текущая проблема: Нажатие кнопки «Построить диаграмму» не вызывает никаких действий в консоли (print не срабатывает). Код до функции построения окна просто не доходит.
План действий:
Проверить файл интерфейса: кнопка должна создаваться до назначения ей команды .configure(command=...).
Заменить файлы на эталонные версии, где добавлены жесткие отладочные принты ([DEBUG] BUTTON CLICKED), чтобы точно видеть, где обрывается цепочка.
Если принты появились, но окна нет — чинить пути к базам данных или библиотеки визуализации.
Чтобы ты мог найти ошибку, высылаю содержимое трех файлов.
1. dukora/modules/project/project_event_logic.py
(Самый важный файл) Ты увидишь:
Есть ли строчка self.project_dbs_folder = PROJECT_DBS_FOLDER.
Полное тело метода get_events_for_chart.
2. dukora/modules/project/chart_modal.py
(Файл самого окна) Ты увидишь:
Импорты в самом верху (особенно import datetime и from tkinter import messagebox).
Начало функции show_chart_modal. Там обязательно должен быть самый первый вызов: print("[DEBUG] show_chart_modal CALLED").
3. dukora/modules/project/project_window.py
(Главный контроллер) Ты увидишь:
Строку def _try_build_chart(): (она находится внутри файла project_chart_ui.py, который вызывается из project_window).
Как именно вызывается show_chart_modal(...) и передаются туда переменные.
Я, вайб кодер - ты, профессиональный программист. Ты делаешь - я вставляю, заменяю.
Кусками не работаем - только целыми фрагментами - метод на метод, файл на файл, от комментария до комментария, от комментария до конца и т.д. - по такому принципу.
==================================================================
Итогом работы должно стать полностью рабочее окно "Построить диаграмму".
===================================================================
# ==========================================
# dukora\modules\project\project_window.py
# ==========================================
import customtkinter as ctk
import tkinter as tk
from tkinter import ttk, messagebox
from typing import List, Any
import sqlite3
import datetime
import os
from pathlib import Path
# Локальные импорты проекта (должны быть после стандартных)
try:
from tkcalendar import DateEntry
except ImportError:
DateEntry = None
try:
import docx
except ImportError:
docx = None
from modules.project.project_event_logic import ProjectEventLogic
from modules.project.project_event_ui import ProjectEventUI
from modules.project.project_create_logic import ProjectCreateLogic
from modules.data.project_database import ProjectDatabase
from modules.project.chart_modal import show_chart_modal
class ProjectWindow(ctk.CTkToplevel):
def __init__(self, master):
super().__init__(master)
self.parent_main = master
self.title("Журнал событий")
# Инициализируем пустым, пока не выбрали проект
self.current_selected_project_code = ""
# === ИНИЦИАЛИЗАЦИЯ ПЕРЕМЕННЫХ (ОДИН РАЗ) ===
self.accordion_data = {}
self.currently_open_button = None
self.var_group = None
self.current_user = getattr(master, 'current_user', None)
screen_height = self.winfo_screenheight()
window_width = 650
initial_height = 380
screen_w = self.winfo_screenwidth()
x_cord = int((screen_w - window_width) / 2)
y_cord = int((screen_h := self.winfo_screenheight() - initial_height) / 2) # Центрируем Y тоже
self.geometry(f"{window_width}x{initial_height}+{x_cord}+{y_cord}")
self.resizable(False, False) # ЗАПРЕЩАЕМ изменение размера
self.grab_set()
self.transient(master)
main_container = ctk.CTkFrame(self, corner_radius=10)
main_container.pack(padx=10, pady=10, fill="both", expand=True)
self._build_title_bar(main_container)
if not hasattr(self, 'manager_db'):
self.manager_db = ProjectDatabase()
self.manager_db.create_tables()
if not hasattr(self, 'event_logic'):
self.event_logic = ProjectEventLogic(self.manager_db)
self._build_project_selector(main_container)
create_item = self._create_accordion_header(main_container, "Создать проект")
events_item = self._create_accordion_header(main_container, "Открыть для ввода событий")
charts_item = self._create_accordion_header(main_container, "Построение диаграмм")
print_item = self._create_accordion_header(main_container, "Настройка печати")
# Наполнение ВСЕХ секций сразу при создании окна
self._fill_create_section(create_item["content"])
self._fill_events_section(events_item["content"])
self._fill_charts_section(charts_item["content"])
self._fill_print_section(print_item["content"])
# Панель кнопок внизу главного окна
button_frame = ctk.CTkFrame(main_container)
button_frame.pack(fill="x", pady=(10, 0), padx=5)
close_btn = ctk.CTkButton(button_frame, text="Закрыть", width=120, command=self.destroy)
close_btn.pack(side="right", padx=10, pady=10)
# Регистрация заголовков аккордеона
for item in [create_item, events_item, charts_item, print_item]:
btn = item["button"]
content = item["content"]
self.accordion_data[btn] = {"content": content}
btn.configure(command=lambda b=btn, c=content: self.toggle_section(b, c))
# Синхронизация размеров ДО открытия первой вкладки
self.update_idletasks()
correct_geometry = self._calculate_window_geometry(collapsed=False)
self.geometry(correct_geometry)
# Открываем первую вкладку
self.toggle_section(create_item["button"], create_item["content"])
def _force_ui_sync(self):
"""
Главная точка входа синхронизации. Вызывается при смене проекта или нажатии кнопки ОБНОВИТЬ ПРОЕКТ.
Находит внутренний Code по отображаемому Cipher и обновляет контекст окна.
"""
current_display_cipher = self.project_selector.get()
if not current_display_cipher or current_display_cipher == "Проектов нет":
print("[SYNC FORCE] Context cleared.")
self.current_selected_project_code = ""
self._update_control_buttons_state(is_enabled=False)
return
try:
self.manager_db.close()
except Exception:
pass
fresh_projects = self.manager_db.list_projects()
selected_tuple = next((p for p in fresh_projects if p[3] == current_display_cipher), None)
if selected_tuple:
internal_code = selected_tuple[2]
self.current_selected_project_code = internal_code
print(f"[SYNC FORCE] Switched to '{current_display_cipher}' -> DB File: {internal_code}.db")
# === ИСПРАВЛЕННЫЙ БЛОК ВЫЗОВА UI ===
events_btn = None
# 1. Находим кнопку заголовка "Открыть для ввода событий"
for btn, data in self.accordion_data.items():
if btn.cget("text") == "Открыть для ввода событий":
events_btn = btn
break
# 2. Если кнопка найдена, получаем её контент-фрейм из словаря
if events_btn and events_btn in self.accordion_data:
content_frame = self.accordion_data[events_btn]["content"]
# 3. Проходим по виджетам внутри этого фрейма (там лежит ProjectEventUI)
for child in content_frame.winfo_children():
if child.__class__.__name__ == "ProjectEventUI":
print("[DIRECT CALL] Updating lists via accordion_data reference.")
child.update_project_dependent_lists(internal_code)
break
self._update_control_buttons_state(is_enabled=True)
else:
print(f"[SYNC FORCE ERROR] Project cipher '{current_display_cipher}' not found.")
self.current_selected_project_code = ""
self._update_control_buttons_state(is_enabled=False)
def _force_refresh_event_ui(self):
"""
Принудительно обновляет списки групп/индикаторов/сотрудников
во вкладке "Открыть для ввода событий" и сбрасывает фильтры в графиках.
Вызывается кнопкой "ОБНОВИТЬ СПИСКИ".
"""
print("[UI SYNC] Manual refresh of lists triggered.")
current_display_cipher = self.project_selector.get()
if not current_display_cipher or current_display_cipher == "Проектов нет":
print("[SYNC FORCE] Context cleared for manual list update.")
# ЖЕСТКАЯ ОЧИСТКА ТИПОВ ДАННЫХ (решение проблемы TclError)
if hasattr(self, 'var_period_str') and isinstance(self.var_period_str, tk.IntVar):
self.var_period_str.set(0)
if hasattr(self, 'var_group') and isinstance(self.var_group, tk.StringVar):
self.var_group.set("")
return
try:
self.manager_db.close()
except Exception:
pass
fresh_projects = self.manager_db.list_projects()
selected_tuple = next((p for p in fresh_projects if p[3] == current_display_cipher), None)
if selected_tuple:
internal_code = selected_tuple[2]
events_section_content = None
target_btn = None
for btn, data in self.accordion_data.items():
if btn.cget("text") == "Открыть для ввода событий":
target_btn = btn
break
if target_btn and target_btn in self.accordion_data:
content_widget = self.accordion_data[target_btn]["content"]
for child in content_widget.winfo_children():
if child.__class__.__name__ == "ProjectEventUI":
events_section_content = child
break
if events_section_content:
print(f"[DIRECT CALL] Updating lists for project code: {internal_code}")
events_section_content.update_project_dependent_lists(internal_code)
if hasattr(events_section_content, '_refresh_event_table'):
events_section_content._refresh_event_table(internal_code)
else:
print("[WARN] ProjectEventUI instance not found by direct tree traversal.")
# Обновление секции графиков + жесткая очистка типов
if hasattr(self, 'cmb_group') and self.cmb_group:
try:
from modules.project.project_event_logic import ProjectEventLogic
logic = ProjectEventLogic(self.manager_db)
groups_raw, _ = logic.load_groups_and_indicators()
names = []
if groups_raw:
try:
if hasattr(groups_raw[0], 'name'):
names = sorted([item.name for item in groups_raw])
elif isinstance(groups_raw[0], tuple):
names = sorted([item[1] for item in groups_raw])
else:
names = sorted(groups_raw)
except Exception:
pass
# Сначала меняем тип внутри Python-объекта
if hasattr(self, 'var_period_str') and isinstance(self.var_period_str, tk.IntVar):
self.var_period_str.set(0)
if hasattr(self, 'var_group') and isinstance(self.var_group, tk.StringVar):
self.var_group.set("")
# Затем обновляем интерфейс
self.cmb_group.configure(values=names)
if names:
self.cmb_group.set(names[0])
else:
self.cmb_group.set("")
if hasattr(self, 'listbox_groups') and self.listbox_groups:
self.listbox_groups.delete(0, tk.END)
except Exception as e:
print(f"[CHART UI REFRESH ERROR]: {e}")
self._update_control_buttons_state(is_enabled=True)
else:
print(f"[SYNC FORCE ERROR] Project cipher '{current_display_cipher}' not found.")
self.current_selected_project_code = ""
self._update_control_buttons_state(is_enabled=False)
def _update_control_buttons_state(self, is_enabled: bool):
target_state = "normal" if is_enabled else "disabled"
text_color = "#FF8C00" if is_enabled else "#A9A9A9"
if hasattr(self, 'btn_sync_context'):
self.btn_sync_context.configure(state=target_state, text_color=text_color)
if hasattr(self, 'btn_sync_lists'):
self.btn_sync_lists.configure(state=target_state, text_color=text_color)
# Нижние кнопки удалены отсюда — ими управляет только проект_chart_ui.py
def _build_title_bar(self, container):
title_lbl = ctk.CTkLabel(container, text="Наполнение проекта", font=ctk.CTkFont(size=20, weight="bold"))
title_lbl.pack(pady=(0, 15))
def _build_project_selector(self, container):
outer_frame = ctk.CTkFrame(container, fg_color="transparent")
outer_frame.pack(fill="x", pady=(5, 10))
btn_font = ("Arial", 11, "bold")
top_frame = ctk.CTkFrame(outer_frame, fg_color="transparent")
top_frame.pack(fill="x", pady=(0, 10))
label_project = ctk.CTkLabel(top_frame, text="Выбрать проект:", anchor="w")
label_project.pack(side="left", padx=(0, 10))
self.project_selector = ctk.CTkComboBox(
top_frame,
values=[],
state="readonly",
width=250
)
self.project_selector.pack(side="left", expand=True, fill="x")
self.project_selector.configure(command=lambda *args: self._force_ui_sync())
control_frame = ctk.CTkFrame(outer_frame, fg_color="transparent")
control_frame.pack(fill="x")
control_frame.grid_columnconfigure(0, weight=1)
control_frame.grid_columnconfigure(1, weight=1)
self.btn_sync_context = ctk.CTkButton(
master=control_frame,
text="ОБНОВИТЬ ПРОЕКТ",
font=btn_font,
fg_color="#000000",
hover_color="#2B2B2B",
command=self._force_ui_sync,
state="disabled"
)
self.btn_sync_context.grid(row=0, column=0, sticky="ew", padx=(0, 5))
self.btn_sync_lists = ctk.CTkButton(
master=control_frame,
text="ОБНОВИТЬ СПИСКИ",
font=btn_font,
fg_color="#000000",
hover_color="#2B2B2B",
command=self._force_refresh_event_ui,
state="disabled"
)
self.btn_sync_lists.grid(row=0, column=1, sticky="ew", padx=(5, 0))
def toggle_section(self, button, content):
is_currently_open = (self.currently_open_button == button)
if self.currently_open_button and not is_currently_open:
prev_content = self.accordion_data[self.currently_open_button]["content"]
prev_content.pack_forget()
self.currently_open_button.configure(fg_color="#34495e")
if is_currently_open:
content.pack_forget()
self.currently_open_button = None
new_geo = self._calculate_window_geometry(collapsed=True)
else:
content.pack(fill="x", padx=5, pady=(0, 5), anchor="center")
self.currently_open_button = button
button.configure(fg_color="#2c3e50")
new_geo = self._calculate_window_geometry(collapsed=False)
self.geometry(new_geo)
self.after(50, self._enforce_onscreen_position)
title = button.cget("text")
# ЛОГИКА ЗАПОЛНЕНИЯ ПРИ ОТКРЫТИИ
if title == "Построение диаграмм" and not is_currently_open:
sync_enabled = False
if hasattr(self, 'btn_sync_context'):
sync_state = self.btn_sync_context.cget("state")
sync_enabled = (sync_state == "normal")
if sync_enabled and self.current_selected_project_code:
self.after(100, lambda: self._load_chart_groups())
else:
# Используем стандартный messagebox из проекта_window.py
messagebox.showinfo(
"Выбор проекта",
"Сначала выберите активный проект над этим окном."
)
def _load_chart_groups(self):
"""Загрузка групп при открытии секции графиков"""
# Очищаем старые данные
if hasattr(self, 'listbox_groups') and self.listbox_groups:
self.listbox_groups.delete(0, tk.END)
if hasattr(self, 'cmb_group') and self.cmb_group:
self.cmb_group.configure(values=[])
self.cmb_group.set("")
# Если проект НЕ выбран
if not self.current_selected_project_code:
print("[CHART LOAD] Project code is empty.")
# Оставляем интерфейс пустым, но рабочим для выбора "Из выпадающего списка"
return
# Если проект ВЫБРАН
try:
from modules.project.project_event_logic import ProjectEventLogic
logic = ProjectEventLogic(self.manager_db)
groups_raw, _ = logic.load_groups_and_indicators()
names = []
if groups_raw:
try:
if hasattr(groups_raw[0], 'name'):
names = sorted([item.name for item in groups_raw])
elif isinstance(groups_raw[0], tuple):
names = sorted([item[1] for item in groups_raw])
else:
names = sorted(groups_raw)
except Exception as e:
print(f"[GROUP PARSE ERROR]: {e}")
if hasattr(self, 'cmb_group') and self.cmb_group:
self.cmb_group.configure(values=names)
if names:
self.cmb_group.set(names[0]) # Выбираем первую группу по умолчанию
except Exception as e:
print(f"[UI LOAD ERROR]: {e}")
def _enforce_onscreen_position(self):
if not self.winfo_exists():
return
self.update_idletasks()
win_w = self.winfo_width()
win_h = self.winfo_height()
screen_w = self.winfo_screenwidth()
screen_h = self.winfo_screenheight()
x = int((screen_w - win_w) / 2)
bottom_edge = self.winfo_rooty() + win_h
overflow = bottom_edge - screen_h + 50
if overflow > 0:
y = max(40, self.winfo_rooty() - overflow)
else:
y = 40
self.geometry(f"+{x}+{y}")
def _calculate_window_geometry(self, collapsed=False):
current_width = self.winfo_width()
if current_width == 1:
current_width = 650
header_height = 50 * 4 + 30
button_panel_height = 70
if collapsed or not self.currently_open_button:
target_height = header_height + 80 + button_panel_height
else:
data = self.accordion_data[self.currently_open_button]
content_widget = data["content"]
self.update_idletasks()
open_content_height = content_widget.winfo_reqheight()
target_height = header_height + open_content_height + button_panel_height
max_h = self.winfo_screenheight() - 50
final_height = min(target_height, max_h)
min_h = 460
final_height = max(final_height, min_h)
return f"{current_width}x{final_height}"
def _create_accordion_header(self, parent, title):
item_container = ctk.CTkFrame(parent, fg_color="#f0f2f5", border_color="#d1d8e0", border_width=1, corner_radius=8)
item_container.pack(fill="x", pady=5)
header_btn = ctk.CTkButton(
item_container,
text=title,
height=45,
font=ctk.CTkFont(weight="bold", size=16),
anchor="w",
fg_color="#34495e",
hover_color="#3c5a72",
)
header_btn.pack(fill="x")
content_frame = ctk.CTkFrame(item_container, fg_color="transparent", corner_radius=0)
return {"button": header_btn, "content": content_frame}
def _handle_project_deletion(self):
current_display = self.project_selector.get()
if not current_display or current_display == "Проектов нет":
messagebox.showwarning("Ошибка", "Сначала выберите проект над аккордеоном.")
return
try:
self.manager_db.close()
except Exception:
pass
fresh_projects = self.manager_db.list_projects()
code_to_delete = next((p for p in fresh_projects if p[3] == current_display), None)
if not code_to_delete:
messagebox.showerror("Ошибка", f"ID для '{current_display}' не найден.")
return
confirm = messagebox.askyesno("Внимание",
f"Удалить проект '{current_display}'?\n"
f"Это действие нельзя отменить.\nФайл событий будет стерт.")
if not confirm:
return
import os
db_file_path = os.path.join(self.event_logic.project_dbs_folder, f"{code_to_delete}.db")
file_was_deleted = False
if os.path.exists(db_file_path):
try:
os.remove(db_file_path)
file_was_deleted = True
except PermissionError:
messagebox.showerror("Доступ запрещен", "Закройте другие программы и попробуйте снова.")
return
success = False
try:
conn = self.manager_db.connect()
cursor = conn.cursor()
cursor.execute("DELETE FROM projects WHERE code = ?", (code_to_delete,))
conn.commit()
success = True
except sqlite3.Error as e:
conn.rollback()
print(f"[DB DELETE ERROR]: {e}")
finally:
self.manager_db.close()
if success and (not os.path.exists(db_file_path) or file_was_deleted):
updated = self.manager_db.list_projects()
names = [p[3] for p in updated]
self.project_selector.configure(values=names)
if names:
self.project_selector.set(names[-1])
self.current_selected_project_code = next((p[2] for p in updated if p[3] == names[-1]), "")
else:
self.project_selector.set("Проектов нет")
self.current_selected_project_code = ""
if hasattr(self, 'tree_projects'):
tree = self.tree_projects
for row in tree.get_children():
tree.delete(row)
for proj in sorted(updated, key=lambda x: x[0]):
tree.insert("", "end", values=proj)
messagebox.showinfo("Готово", f"'{current_display}' удален.")
else:
messagebox.showerror("Ошибка", "Не удалось обновить реестр. Перезапустите программу.")
def _fill_create_section(self, frame):
from modules.data.project_database import ProjectDatabase
from modules.project.project_create_logic import ProjectCreateLogic
if not hasattr(self, 'manager_db'):
self.manager_db = ProjectDatabase()
self.manager_db.create_tables()
self.event_logic = ProjectEventLogic(self.manager_db)
logic = ProjectCreateLogic(self.manager_db)
form_frame = ctk.CTkFrame(frame, fg_color="transparent")
form_frame.pack(fill="x", expand=True, padx=10, pady=10)
form_frame.grid_columnconfigure(1, weight=1)
entries = {}
fields = [
("Название:", "human_name"),
("Шифр:", "short_code"),
("Код (ID):", "full_code"),
("Дата открытия:", "date"),
("Ответственный:", "responsible")
]
for i, (label_text, key) in enumerate(fields):
ttk.Label(form_frame, text=label_text).grid(row=i, column=0, sticky="w", pady=(0, 5), padx=(0, 10))
entry = ctk.CTkEntry(form_frame)
entry.grid(row=i, column=1, sticky="ew", pady=(0, 5))
entries[key] = entry
if key == "date":
today = datetime.datetime.now().strftime("%Y-%m-%d")
entry.insert(0, today)
action_frame = ctk.CTkFrame(form_frame, fg_color="transparent")
action_frame.grid(row=len(fields), column=0, columnspan=2, sticky="ew", pady=(5, 0))
for i in range(4): action_frame.grid_columnconfigure(i, weight=1)
create_btn = ctk.CTkButton(
action_frame,
text="Создать",
fg_color="#3B82F6",
hover_color="#2563EB",
command=lambda n=entries['human_name'], sc=entries['short_code'], c=entries['full_code'], d=entries['date'], r=entries['responsible']: self._handle_project_creation(
{
'human_name': n.get(),
'short_code': sc.get(),
'full_code': c.get(),
'description': "",
'date': d.get(),
'responsible': r.get()
},
logic,
lambda: [widget.delete(0, "end") for widget in entries.values()]
)
)
create_btn.grid(row=0, column=0, sticky="ew", padx=(0, 5))
clear_btn = ctk.CTkButton(
action_frame,
text="Очистить",
fg_color="#6B7280",
hover_color="#4B5563",
command=lambda: [widget.delete(0, "end") for widget in entries.values()]
)
clear_btn.grid(row=0, column=1, sticky="ew", padx=5)
delete_btn = ctk.CTkButton(
action_frame,
text="Удалить",
fg_color="#EF4444",
hover_color="#B91C1C",
command=self._handle_project_deletion
)
delete_btn.grid(row=0, column=2, sticky="ew", padx=5)
def _handle_project_creation(self, data_dict: dict, logic_instance: ProjectCreateLogic, on_success_callback):
success, message = logic_instance.create_project(data_dict)
if not success:
messagebox.showerror("Ошибка создания", message)
return
messagebox.showinfo("Проект создан", message)
on_success_callback()
try:
self.manager_db.close()
except Exception:
pass
fresh_tuples = self.manager_db.list_projects()
fresh_names = [p[1] for p in fresh_tuples]
# Безопасное пересоздание селектора проектов
parent_widget = self.project_selector.master
old_selector = self.project_selector
old_selector.pack_forget()
old_selector.destroy()
self.project_selector = ctk.CTkComboBox(
master=parent_widget,
values=fresh_names,
state="readonly",
width=250
)
self.project_selector.pack(side="left")
if fresh_names:
selected_proj_tuple = next((p for p in fresh_tuples if p[1] == fresh_names[-1]), None)
display_text = selected_proj_tuple[2] if selected_proj_tuple else fresh_names[-1]
self.project_selector.set(display_text)
self.current_selected_project_code = selected_proj_tuple[2] if selected_proj_tuple else ""
else:
self.project_selector.set("Проектов нет")
self.current_selected_project_code = ""
self.project_selector.configure(command=lambda *args: self._force_ui_sync())
if hasattr(self, 'tree_projects'):
tree = self.tree_projects
for row in tree.get_children():
tree.delete(row)
for proj in sorted(fresh_tuples, key=lambda x: x[0]):
tree.insert("", "end", values=proj)
current_title = self.currently_open_button.cget("text") if self.currently_open_button else ""
if current_title == "Открыть для ввода событий":
for child in self.currently_open_content.winfo_children():
if isinstance(child, ProjectEventUI):
try:
child.update_project_dependent_lists()
except Exception as update_err:
print(f"[LOG] Ошибка обновления UI событий: {update_err}")
break
def _fill_events_section(self, frame):
from modules.project.project_event_ui import ProjectEventUI
from modules.project.project_event_logic import ProjectEventLogic
if not hasattr(self, 'manager_db'):
self.manager_db = ProjectDatabase()
self.manager_db.create_tables()
if not hasattr(self, 'event_logic'):
self.event_logic = ProjectEventLogic(self.manager_db)
ui = ProjectEventUI(frame, self, self.event_logic)
ui.pack(fill="both", expand=True)
current_proj_code = getattr(self, 'current_selected_project_code', "")
if current_proj_code:
ui.after(100, lambda pc=current_proj_code: (
ui.update_project_dependent_lists(pc),
ui._refresh_event_table(pc)
))
def _fill_charts_section(self, frame):
from modules.project.project_chart_ui import build_charts_interface
charts_container = ctk.CTkFrame(frame)
charts_container.pack(fill="both", expand=True, padx=5, pady=5)
# Больше НЕ используем pack_propagate(False) здесь,
# так как project_chart_ui использует grid и рассчитывает свои размеры.
build_charts_interface(charts_container, self)
def _fill_print_section(self, frame):
if DateEntry is None:
error_lbl = ctk.CTkLabel(frame, text="Ошибка: установите библиотеку\npip install tkcalendar", text_color="red")
error_lbl.pack(expand=True)
return
def _generate_word_report(self, group, report_date, orientation):
if docx is None:
messagebox.showerror("Библиотека не найдена", "Для экспорта нужна библиотека 'python-docx'.\nВыполните:\npip install python-docx")
return
def on_closing(self):
try:
self.after_cancel("all")
except Exception as e:
# Логируем ошибку отмены задач планировщика, но не прерываем закрытие
print(f"[WARN] Failed to cancel scheduled events: {e}")
for item in list(self.accordion_data.values()):
try:
content = item.get("content")
if content:
content.destroy()
except Exception as e:
# Логируем ошибки уничтожения виджетов аккордеона
print(f"[WARN] Failed to destroy accordion content: {e}")
try:
self.destroy()
except Exception as e:
# Логируем финальную ошибку закрытия корневого окна
print(f"[CRITICAL] Failed to close ProjectWindow: {e}")
# =================================================================
# =================================================================
# dukora/modules/project/project_event_logic.py
# =================================================================
import os
import sys # <-- Добавьте эту строку, если её нет
import sqlite3
import datetime # <-- Обязательно должно быть здесь
from typing import List, Tuple, Dict
from pathlib import Path
from modules.data.project_database import ProjectDatabase
# --- НАДЕЖНОЕ ОПРЕДЕЛЕНИЕ ПУТИ К БАЗАМ ---
try:
# Используем нашу рабочую функцию из resources.py
from modules.utils.resources import get_project_root
ROOT_DIR = get_project_root()
DB_FOLDER = ROOT_DIR / "db"
DATABASE_DB_PATH = DB_FOLDER / "database.db"
PROJECT_DBS_FOLDER = ROOT_DIR / "project_dbs"
except Exception as e:
print(f"[FATAL] Не удалось определить корень проекта для путей к БД: {e}")
sys.exit(1)
class ProjectEventLogic:
def __init__(self, manager_db: ProjectDatabase):
self.manager_db = manager_db # Теперь снова можем брать список проектов
# Пути берем из глобальных констант модуля (которые мы настроили ранее)
self.project_dbs_folder = PROJECT_DBS_FOLDER
os.makedirs(self.project_dbs_folder, exist_ok=True)
# --- СПИСОК ПРОЕКТОВ (из projects.db) ---
def load_project_list(self) -> List[Tuple]:
return self.manager_db.list_projects()
# --- ГРУППЫ И ПОКАЗАТЕЛИ (из database.db - без изменений) ---
def load_groups_and_indicators(self) -> Tuple[List[str], Dict[str, str]]:
if not DATABASE_DB_PATH.exists():
return [], {}
try:
conn = sqlite3.connect(DATABASE_DB_PATH)
cursor = conn.cursor()
# Группы - просто список имен
cursor.execute("SELECT name FROM groups ORDER BY name")
group_names = [row[0] for row in cursor.fetchall()]
name_to_code_map = {}
if group_names:
cursor.execute("SELECT code, name FROM groups ORDER BY name")
rows = cursor.fetchall()
name_to_code_map = {name: code for code, name in rows}
conn.close()
return group_names, name_to_code_map
except Exception as e:
print(f"[LOGIC ERROR] Failed to read from master DB: {e}")
return [], {}
def load_indicators_for_group(self, group_code: str):
if not DATABASE_DB_PATH.exists():
return []
try:
conn = sqlite3.connect(DATABASE_DB_PATH)
cursor = conn.cursor()
# Индикаторы - просто список имен
cursor.execute("SELECT name, code FROM indicators WHERE group_code = ? ORDER BY code", (group_code,))
indicators = cursor.fetchall()
conn.close()
return indicators
except Exception as e:
print(f"[LOGIC ERROR] Failed to read indicators: {e}")
return []
# --- ПОЛУЧЕНИЕ СОБЫТИЙ ДЛЯ ГРАФИКА ---
def get_events_for_chart(
self,
project_code: str,
group_names: List[str],
date_from: datetime.date,
date_to: datetime.date
) -> List[Tuple]:
db_path = Path(self.project_dbs_folder) / f"{project_code}.db"
if not db_path.exists():
print(f"[DEBUG] DB file does not exist: {db_path}")
return []
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Используем CAST или функции даты для надежности
query = f'''
SELECT event_data, group_name, COUNT(*) as cnt
FROM project_data
WHERE group_name IN ({','.join('?' for _ in group_names)})
AND date(event_data) >= date(?)
AND date(event_data) < date(?)
GROUP BY date(event_data), group_name
ORDER BY date(event_data) ASC
'''
params = [*group_names, date_from.strftime('%Y-%m-%d'), date_to.strftime('%Y-%m-%d')]
print(f"[DEBUG QUERY] Executing on {db_path.name}")
print(f"[DEBUG PARAMS] Groups: {group_names}, From: {params[-2]}, To: {params[-1]}")
cursor.execute(query, params)
events = cursor.fetchall()
print(f"[DEBUG RESULTS] Found rows: {len(events)}")
if events:
print(f"[DEBUG SAMPLE] First row: {events[0]}")
conn.close()
return events
except Exception as e:
print(f"[LOGIC ERROR] Failed to read events for chart: {e}")
return []
# --- ЗАПИСЬ СОБЫТИЙ (без изменений) ---
def add_event_to_db(self, selected_data: Dict, event_text: str, date_str: str, direction: int) -> tuple[bool, str]:
db_path = os.path.join(self.project_dbs_folder, f"{selected_data['project_code']}.db")
is_new_db = not os.path.exists(db_path)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# === КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ СТРУКТУРЫ ТАБЛИЦЫ ===
# Проверяем, есть ли нужная колонка
cursor.execute("PRAGMA table_info(project_data)")
columns = [col[1] for col in cursor.fetchall()]
if 'indicator_code' not in columns or 'event_sign' not in columns:
print("[DB FIX] Table structure outdated or missing. Recreating project_data...")
# Удаляем старую кривую таблицу
cursor.execute("DROP TABLE IF EXISTS project_data")
# Создаем новую со всеми правильными полями
cursor.execute('''
CREATE TABLE project_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_code TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
group_name TEXT,
group_code TEXT,
indicator_name TEXT,
indicator_code TEXT,
event_data DATE,
event_sign INTEGER, -- ВАЖНО: именно Integer, как ожидает ваша логика (+/-)
event_code TEXT,
event_text_content TEXT
)
''')
conn.commit()
# Теперь запрос COUNT точно найдет колонку indicator_code
cursor.execute(
"SELECT COUNT(*) FROM project_data WHERE project_code = ? AND indicator_code = ?",
(selected_data['project_code'], selected_data['indicator_code'])
)
count = cursor.fetchone()[0]
# Код события формируется только если выбран индикатор
event_code = f"{selected_data['indicator_code']}_{count + 1}" if selected_data['indicator_code'] else f"EVENT_{count + 1}"
try:
cursor.execute('''
INSERT INTO project_data (
project_code, group_name, group_code, indicator_name, indicator_code,
event_data, event_sign, event_code, event_text_content
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
selected_data['project_code'],
selected_data['group_name'],
selected_data['group_code'],
selected_data['indicator_name'],
selected_data['indicator_code'],
date_str,
direction, # Передаем число 1 или -1 напрямую
event_code,
event_text
))
conn.commit()
return True, f"Данные добавлены. Код события: {event_code}"
except Exception as e:
conn.rollback()
return False, f"Ошибка записи в БД: {e}"
finally:
conn.close()
# ========================================
# ========================================
# dukora\modules\project\chart_modal.py
# ========================================
import customtkinter as ctk
import pandas as pd
import plotly.graph_objects as go
from io import BytesIO
import datetime
import sys
from typing import List, Any
# Импортируем вашу логику работы с событиями
try:
from modules.project.project_event_logic import ProjectEventLogic
except ImportError:
ProjectEventLogic = None
def fetch_real_chart_data(project_code: str, group_names: List[str], start_date: str, step_days: int):
data = []
if not ProjectEventLogic or not project_code:
print("[CHART] Logic is missing.")
return pd.DataFrame()
try:
# Создаем инстанс логики БЕЗ параметров, так как путь берется из глобальных констант модуля logic
from modules.project.project_event_logic import ProjectEventLogic
logic = ProjectEventLogic(None)
end_date_dt = datetime.datetime.strptime(start_date, '%d.%m.%Y') + datetime.timedelta(days=30)
current_dt = datetime.datetime.strptime(start_date, '%d.%m.%Y')
while current_dt <= end_date_dt:
next_dt = current_dt + datetime.timedelta(days=step_days)
# Вызываем ВАШ НОВЫЙ МЕТОД
raw_events = logic.get_events_for_chart(
project_code=project_code,
group_names=group_names,
date_from=current_dt.date(),
date_to=next_dt.date()
)
# Агрегируем результат
counts = {g: 0 for g in group_names}
for row in raw_events:
# row = (event_data, group_name, count)
grp_name = row[1]
cnt = row[2]
if grp_name in counts:
counts[grp_name] += cnt
for g_name, total_cnt in counts.items():
data.append({
'date': current_dt.strftime('%Y-%m-%d'),
'group': g_name,
'count': total_cnt
})
current_dt = next_dt
df = pd.DataFrame(data)
return df
except Exception as e:
print(f"[LOGIC ERROR] Failed to read events for chart: {e}")
return []
def show_chart_modal(master, project_code: str, group_names: List[str], start_date: str, step_days: int, chart_type: str):
df = fetch_real_chart_data(project_code, group_names, start_date, step_days)
# Проверка на пустоту данных
if df.empty or df['count'].sum() == 0:
ctk.CTkMessagebox.showinfo("Данные", "За данный период событий не найдено.")
return
modal = ctk.CTkToplevel(master)
title_name = ', '.join(group_names[:3]) + ("..." if len(group_names) > 3 else "")
modal.title(f"Интенсивность событий: {title_name}")
modal.geometry("800x600")
modal.grab_set()
modal.transient(master)
modal.grid_rowconfigure(0, weight=1)
modal.grid_columnconfigure(0, weight=1)
fig = go.Figure()
dates = pd.to_datetime(df['date'])
if chart_type == "Гистограмма":
for name in group_names:
subset = df[df['group'] == name]
if not subset.empty:
fig.add_trace(go.Bar(x=subset['date'], y=subset['count'], name=name))
else:
for name in group_names:
subset = df[df['group'] == name]
if not subset.empty:
fig.add_trace(go.Scatter(x=subset['date'], y=subset['count'], mode='lines+markers', name=name))
fig.update_layout(barmode='group' if chart_type == "Гистограмма" else 'overlay',
template="plotly_white", legend_title_text="Группы")
canvas = tk.Canvas(modal, bg="#ffffff", highlightthickness=0)
canvas.pack(fill="both", expand=True, padx=10, pady=10)
try:
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
img_bytes = fig.to_image(format="png", engine="kaleido", scale=2)
buf = BytesIO(img_bytes)
fig_mpl, ax = plt.subplots(figsize=(8, 6), dpi=100)
ax.imshow(plt.imread(buf))
ax.axis('off')
agg_canvas = FigureCanvasTkAgg(fig_mpl, master=canvas)
agg_canvas.draw()
agg_canvas.get_tk_widget().pack(fill="both", expand=True)
except ImportError:
error_lbl = ctk.CTkLabel(modal, text="pip install matplotlib pillow kaleido", text_color="red")
error_lbl.pack(expand=True)
return
button_frame = ctk.CTkFrame(modal)
button_frame.pack(fill="x", pady=(0, 10), padx=10)
def close_modal():
modal.destroy()
plt.close('all')
copy_btn = ctk.CTkButton(button_frame, text="Копировать", fg_color="#22C55E", command=lambda: fig.write_image("temp_chart.png"))
copy_btn.pack(side="left", fill="x", expand=True, padx=5)
close_btn = ctk.CTkButton(button_frame, text="Закрыть", fg_color="#3B82F6", command=close_modal)
close_btn.pack(side="right", fill="x", expand=True, padx=5)
# ========================================
#================================================
# 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
# =================================================================