Загрузка данных
# =========================================
# dukora\modules\project\project_window.py
# =========================================
import customtkinter as ctk
from tkinter import ttk
from modules.project.project_event_logic import ProjectEventLogic
from typing import List
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_ui import ProjectEventUI
from modules.project.project_create_logic import ProjectCreateLogic
from modules.data.project_database import ProjectDatabase
class ProjectWindow(ctk.CTkToplevel):
def __init__(self, master):
super().__init__(master)
self.parent_main = master
self.title("Журнал событий")
# === ИНИЦИАЛИЗАЦИЯ ПЕРЕМЕННЫХ (ОДИН РАЗ) ===
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)
taskbar_offset_top = 40
y_cord = taskbar_offset_top
self.geometry(f"{window_width}x{initial_height}+{x_cord}+{y_cord}")
self.resizable(True, 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"])
# Панель кнопок внизу главного окна
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)
# Вызываем наполнение секции графиков
self._fill_charts_section(charts_item["content"])
self._fill_print_section(print_item["content"])
# Регистрация заголовков аккордеона (цикл должен быть в самом конце перед геометрией)
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"])
self.protocol("WM_DELETE_WINDOW", self.on_closing)
self.after(50, self._enforce_onscreen_position)
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.")
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
for item in self.accordion_data.values():
if item and "content" in item:
content_widget = item["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)
else:
print("[WARN] ProjectEventUI instance not found in accordion cache.")
def _update_control_buttons_state(self, is_enabled: bool):
"""Меняет цвет кнопок ОБНОВИТЬ / ОБНОВИТЬ СПИСКИ вверху окна"""
target_state = "normal" if is_enabled else "disabled"
# Логика ТЗ: Оранжевый (#FF8C00) - если есть изменения/синхронизация нужна.
# Серый (#A9A9A9) - если контекст пуст или заблокирован.
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)
# НИЖНИЕ КНОПКИ СЕКЦИИ ГРАФИКОВ БОЛЬШЕ НЕ ТРОГАЕМ ЗДЕСЬ.
# Они управляются своим локальным кодом в project_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:
# Окно открылось, даем ему долю секунды отрисоваться
self.after(100, lambda: self._load_chart_groups())
def _load_chart_groups(self):
"""Загрузка групп при открытии секции графиков"""
try:
logic = ProjectEventLogic(self.manager_db)
groups_raw, _ = logic.load_groups_and_indicators()
items = []
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)
items.extend(names)
except Exception:
pass
# Устанавливаем значения ТОЛЬКО если список существует
if hasattr(self, 'cmb_group') and self.cmb_group:
self.cmb_group.configure(values=items)
if items:
self.cmb_group.set(items[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, height=450)
charts_container.pack(fill="x", expand=False, padx=5, pady=5)
# КРИТИЧЕСКИ ВАЖНО: запрещаем рамке расти по вертикали
charts_container.pack_propagate(False)
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}")