Загрузка данных
Работаем с окном "Наполнение проекта" с заголовком аккордеона "Построить диаграмму". Начинаем работу с переименования окна "Показать графики" в "Построить диаграмму".
Это новое окно, поэтому его реализацию начинаем с чистого листа.
Все элементы управления в этом окне должны работать с проектом, подключенным в верху окна аккордеона "Наполнение проекта" в выпадающем списке. С выбором в нем другого проекта все элементы в "Построить диаграмму" должны обновляться и переключаться для работы с новым проектом.
Окно организовать на кастомткинкере (СТК). Работаем по схеме - один заголовок - 2 файла: один для интерфейса, другой для функционала. Название файлов этого окна должны начинаться с префикса project_ , все пути импортов организовать по модулю resources.ру. Ниже расписываю что должно быть в окне "Построить диаграмму".
1. Блок "Выбрать группу": рядом выпадающий список с группами, рядом кнопка синего цвета "Добавить в список" - все в одну строчку. По этому выбору будет в дальнейшем строиться график/гистограмма событий либо выбранной группы, либо во всех группах вместе. Использоваться, видимо, будет база данных database.db или masterdata.db (справочная база).
2. Блок "Выбрать дату": рядом выпадающий список с Календарем. Выбирается с какого срока создавать график/гистаграмму. Привязывается к событиям базы конкретного проекта.
3. Блок "Выбрать периодичность": рядом поле для указания промежутков, рядом в скобках (дней). Пользователем выбирается шаг на оси.
4. Блок "Выбрать диаграмму": рядом выпадающий список. В списке 2 значения: "Гистограмма" и "График".
Все четыре блока разместить строго по вертикальной линии друг под другом. Блоки должны быть выровнены: заголовки по левому краю, поля по вертикальной линии.
5. Ниже всех блоков две кнопки: красная "Сбросить настройки" и синяя "Построить диаграмму". Кнопки одинакового размера, рзмещаются на всю ширину окна в одну строчку.
6. При нажатии на кнопку "Построить диаграмму" открывается модальное окно (его заголовок по выбранной группе "Интенсивность событий в группе N...") с диаграммой (либо гистограмма, либо график). Размер окна приблизительно А5. Диаграмма строится:
- по оси Y - частота событий, вверху маркера/столбика - число с количеством.
- по оси X - дата.
7. В модальном окне диаграммы в самом низу разместить 2 кнопки:
- зеленую - "Копировать" ( происходит копирование диаграммы в буфер обмена).
- синюю - "Закрыть" (закрывается модальное окно).
Я, вайб кодер - ты, профессиональный программист. Ты делаешь - я вставляю, заменяю.
Кусками не работаем - только целыми фрагментами - метод на метод, файл на файл, от комментария до комментария, от комментария до конца и т.д. - по такому принципу.
- Файл main_interface.ру принципиально не менять, это только витрина и кнопки.
- Программа будет собираться руинсталлером, поэтому это надо иметь ввиду.
- все файлы для работы с окном должны лежать по адресу dukora\modules\project\.
- файл для работы с базами database_module.ру расположен по адресу dukora\modules\data\ (справочниками).
- файл для работы с базами project_database.ру расположен по адресу dukora\modules\data\ (с созданными проектами).
- сами базы расположены по адресу: dukora\db - это и projects, и database, и masterdata.
- базы, создаваемых пользователем проектов лежат по адресу: dukora\project_dbs
- если какие то вопросы есть - задавай.
==================================================================
Итогом работы должно стать полностью рабочее окно "Построить диаграмму". Изначально, чтобы не выдавало ошибки создать можно часть элементов заблокировать через pass. Затем постепенно шаг за шагом будем подключать элемент за элементом.
===================================================================
Есть такой файл - project_window.py. Однако он уже избыточен. Поэтому его использовать нельзя - необходимо создать другой такой файл, например project_window_2.py
# =================================================================
# 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.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.accordion_data = {}
self.currently_open_button = None
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"])
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))
# === КРИТИЧЕСКИ ВАЖНЫЙ БЛОК СИНХРОНИЗАЦИИ РАЗМЕРОВ ===
# 1. Заставляем Tkinter измерить реальную высоту всех созданных фреймов (включая тяжелую таблицу событий)
self.update_idletasks()
# 2. Теперь рассчитываем правильную высоту окна на основе реальных размеров контента
correct_geometry = self._calculate_window_geometry(collapsed=False)
self.geometry(correct_geometry)
# 3. Открываем нужную секцию ПОСЛЕ установки размера (чтобы не было визуального прыжка collapse -> expand)
self.toggle_section(create_item["button"], create_item["content"])
# 4. Панель кнопок должна быть создана ДО финальной проверки границ экрана
button_frame = ctk.CTkFrame(main_container)
button_frame.pack(fill="x", pady=(10, 0))
close_btn = ctk.CTkButton(button_frame, text="Закрыть", width=120, command=self.destroy)
close_btn.pack(side="right", padx=10, pady=10)
self.protocol("WM_DELETE_WINDOW", self.on_closing)
# 5. Финальная проверка: если из-за таскбара окно все равно обрезано, сдвигаем его вверх
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"
# Пункт 1 ТЗ: активный цвет - оранжевый вместо белого/светло-серого
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)
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()
button.configure(fg_color="#34495e")
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")
button.configure(fg_color="#2c3e50")
self.currently_open_button = button
new_geo = self._calculate_window_geometry(collapsed=False)
self.geometry(new_geo)
self.after(50, self._enforce_onscreen_position)
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):
if DateEntry is None:
error_lbl = ctk.CTkLabel(frame, text="Ошибка: установите библиотеку\npip install tkcalendar", text_color="red")
error_lbl.pack(expand=True)
return
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_ui.py
# ==========================================
import customtkinter as ctk
from tkinter import ttk
import tkinter as tk
import datetime
import os
import sqlite3
from .project_event_logic import ProjectEventLogic
from tkcalendar import DateEntry
from tkinter import messagebox
# ==========================================
# ОСНОВНОЙ КЛАСС ОКНА СОБЫТИЙ
# ==========================================
class ProjectEventUI(ctk.CTkFrame):
EDIT_MODE_COLOR = "#8B4513"
def __init__(self, master, window_ref, logic: ProjectEventLogic):
super().__init__(master, fg_color="transparent")
self.window_ref = window_ref
self.logic = logic
self.group_names_list = []
self.indicator_names_list = []
self.group_names_to_codes = {}
self.indicator_cache = {}
self.editing_event_code = None
self._setup_widgets()
self._bind_global_events()
self.update_project_dependent_lists()
self.after(50, self._initial_load)
def _initial_load(self):
print(f"[INIT LOAD] Waiting for UI sync...")
current_proj_code = getattr(self.window_ref, 'current_selected_project_code', "")
if current_proj_code and current_proj_code != "Проектов нет":
self._refresh_event_table(current_proj_code)
def _setup_widgets(self):
main_form = ctk.CTkFrame(self, fg_color="transparent")
main_form.pack(fill="both", expand=True, padx=10, pady=10)
main_form.grid_columnconfigure(1, weight=1)
lbl_group = ctk.CTkLabel(main_form, text="Выбрать группу:", anchor="w")
lbl_group.grid(row=0, column=0, sticky="w", pady=(0, 5), padx=(0, 10))
self.cmb_group = ctk.CTkComboBox(
main_form,
state="readonly",
width=250,
command=self._on_group_change
)
self.cmb_group.grid(row=0, column=1, sticky="ew", pady=(0, 5))
lbl_indicator = ctk.CTkLabel(main_form, text="Выбрать показатель:", anchor="w")
lbl_indicator.grid(row=1, column=0, sticky="w", pady=(0, 5), padx=(0, 10))
self.cmb_indicator = ctk.CTkComboBox(
main_form,
state="readonly",
width=250,
command=lambda choice: self._refresh_event_table(self.window_ref.current_selected_project_code)
)
self.cmb_indicator.grid(row=1, column=1, sticky="ew", pady=(0, 10))
lbl_event = ctk.CTkLabel(main_form, text="Содержание события:", anchor="w")
lbl_event.grid(row=2, column=0, sticky="nw", pady=(0, 5), padx=(0, 10))
event_container = ctk.CTkFrame(main_form, fg_color="#F0F0F0", border_width=1, border_color="#A9A9A9")
event_container.grid(row=2, column=1, sticky="ew", pady=(0, 5), ipadx=2, ipady=2)
self.txt_event = ctk.CTkTextbox(event_container, height=80, wrap="word", fg_color="white")
self.txt_event.pack(fill="both", expand=True, padx=2, pady=2)
paste_btn = ctk.CTkButton(main_form, text="Вставить", fg_color="#2B2B2B", hover_color="#3C3C3C", command=self._paste_from_clipboard)
paste_btn.grid(row=3, column=1, sticky="e", pady=(0, 10), ipadx=10)
meta_frame = ctk.CTkFrame(main_form, fg_color="transparent")
meta_frame.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(0, 10))
meta_frame.grid_columnconfigure(1, weight=1)
ttk.Label(meta_frame, text="Дата события:").grid(row=0, column=0, sticky="w", padx=(0, 10))
self.date_entry = DateEntry(
master=meta_frame,
width=12,
background='darkblue',
foreground='white',
bordercolor='gray',
headersbackground='darkgray',
normalbackground='white',
normalforeground='black',
weekendbackground='lightblue',
weekendforeground='red',
date_pattern='dd.MM.yyyy'
)
today = datetime.datetime.now()
self.date_entry.set_date(today)
self.date_entry.grid(row=0, column=1, sticky="w")
ttk.Label(meta_frame, text="Направленность:").grid(row=0, column=2, sticky="w", padx=(20, 10))
self.dir_var = tk.IntVar(value=1)
rb_plus = ctk.CTkRadioButton(meta_frame, text="(+)", variable=self.dir_var, value=1)
rb_plus.grid(row=0, column=3, sticky="w")
rb_minus = ctk.CTkRadioButton(meta_frame, text="(-)", variable=self.dir_var, value=-1)
rb_minus.grid(row=0, column=4, sticky="w")
ctrl_frame = ctk.CTkFrame(main_form, fg_color="transparent")
ctrl_frame.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(0, 10))
for i in range(4): ctrl_frame.grid_columnconfigure(i, weight=1)
self.btn_add = ctk.CTkButton(ctrl_frame, text="Добавить", fg_color="#3B82F6", command=self._add_event)
self.btn_add.grid(row=0, column=0, sticky="ew", padx=2)
self.btn_edit = ctk.CTkButton(ctrl_frame, text="Редактировать", fg_color="#10B981", command=self._enter_edit_mode)
self.btn_edit.grid(row=0, column=1, sticky="ew", padx=2)
self.btn_save = ctk.CTkButton(ctrl_frame, text="Сохранить", state="disabled", fg_color=self.EDIT_MODE_COLOR, command=self._save_edited_event)
self.btn_save.grid(row=0, column=2, sticky="ew", padx=2)
self.btn_del = ctk.CTkButton(ctrl_frame, text="Удалить", fg_color="#EF4444", command=self._delete_selected_event)
self.btn_del.grid(row=0, column=3, sticky="ew", padx=2)
tree_container = ctk.CTkFrame(main_form, fg_color="transparent")
tree_container.grid(row=6, column=0, columnspan=2, sticky="nsew", pady=(10, 0))
tree_container.grid_rowconfigure(0, weight=1); tree_container.grid_columnconfigure(0, weight=1)
style = ttk.Style()
theme_name = "default" # Жесткая фиксация темы для избежания конфликтов CTk
style.theme_use(theme_name)
columns = ("num", "code", "date", "sign", "event")
self.tree = ttk.Treeview(tree_container, columns=columns, show="headings", selectmode="browse", height=8)
self.tree.heading("num", text="№п/п"); self.tree.column("num", width=40, anchor="center", stretch=False)
self.tree.heading("code", text="Код"); self.tree.column("code", width=100, anchor="center", stretch=False)
self.tree.heading("date", text="Дата"); self.tree.column("date", width=110, anchor="center", stretch=False)
self.tree.heading("sign", text="+/-"); self.tree.column("sign", width=50, anchor="center", stretch=False)
self.tree.heading("event", text="Событие"); self.tree.column("event", minwidth=200, stretch=True)
self.tree.pack(side="left", fill="both", expand=True)
scrollbar = ctk.CTkScrollbar(tree_container, orientation="vertical", command=self.tree.yview)
scrollbar.pack(side="right", fill="y")
self.tree.configure(yscrollcommand=scrollbar.set)
search_frame = ctk.CTkFrame(main_form, fg_color="transparent")
search_frame.grid(row=7, column=0, columnspan=2, sticky="ew", pady=(10, 0))
search_frame.grid_columnconfigure(1, weight=1)
ttk.Label(search_frame, text="Наберите код:").grid(row=0, column=0, sticky="w", padx=(0, 5))
self.search_entry = ctk.CTkEntry(search_frame)
self.search_entry.grid(row=0, column=1, sticky="ew", padx=(0, 5))
find_btn = ctk.CTkButton(search_frame, text="Искать", fg_color="#3B82F6", command=self._search_by_code)
find_btn.grid(row=0, column=2, sticky="e")
def update_project_dependent_lists(self, forced_project_code=None):
proj_code = forced_project_code or getattr(self.window_ref, 'current_selected_project_code', "")
if not proj_code:
placeholder_groups = ["Все группы"]
placeholder_inds = ["Все показатели"]
self.cmb_group.configure(values=placeholder_groups)
self.cmb_group.set(placeholder_groups[0])
self.cmb_indicator.configure(values=placeholder_inds)
self.cmb_indicator.set(placeholder_inds[0])
return
groups_raw, name_to_code_map = self.logic.load_groups_and_indicators()
safe_groups = []
if groups_raw is not None:
try:
if hasattr(groups_raw[0], 'name'):
safe_groups = sorted(list(set([item.name for item in groups_raw])))
elif isinstance(groups_raw[0], tuple) and len(groups_raw[0]) > 1:
safe_groups = sorted(list(set([item[1] for item in groups_raw])))
elif isinstance(groups_raw[0], str):
safe_groups = sorted(list(set(groups_raw)))
except Exception:
pass
final_group_list = ["Все группы"] + safe_groups if safe_groups else ["Все группы"]
self.group_names_list = final_group_list
self.group_names_to_codes = {"": ""}
if isinstance(name_to_code_map, dict):
self.group_names_to_codes.update(name_to_code_map)
self.cmb_group.configure(values=final_group_list)
self.cmb_group.set(final_group_list[0])
all_indicators = []
seen_names = set()
for cached_list in self.indicator_cache.values():
for item in cached_list:
name = item[0]
if name not in seen_names:
all_indicators.append(item)
seen_names.add(name)
all_indicators.sort(key=lambda x: x[0])
ind_names = ["Все показатели"] + [item[0] for item in all_indicators]
self.indicator_names_list = ind_names
self.cmb_indicator.configure(values=ind_names)
self.cmb_indicator.set(ind_names[0])
self.after_idle(lambda code=proj_code: (
self.window_ref._update_control_buttons_state(True),
self._refresh_event_table(code)
))
def _on_group_change(self, event=None):
group_name = self.cmb_group.get()
if group_name == "Все группы":
self.cmb_indicator.set("")
self.indicator_names_list = ["Все показатели"]
self.cmb_indicator.configure(values=["Все показатели"])
self._update_indicator_combobox(group_name)
def _update_indicator_combobox(self, group_name: str):
self.cmb_indicator.set("")
if not group_name or group_name == "Все группы":
placeholder_list = ["Все показатели"]
self.indicator_names_list = placeholder_list
self.cmb_indicator.configure(values=placeholder_list)
self.cmb_indicator.set("Все показатели")
return
group_code = self.group_names_to_codes.get(group_name)
if not group_code:
self.cmb_indicator.configure(values=["Все показатели"])
self.cmb_indicator.set("Все показатели")
return
indicators = self.logic.load_indicators_for_group(group_code)
ind_names = ["Все показатели"] + [item[0] for item in indicators]
self.indicator_names_list = ind_names
self.cmb_indicator.configure(values=ind_names)
self.indicator_cache[group_code] = indicators
if len(ind_names) > 1:
self.cmb_indicator.set(ind_names[1])
else:
self.cmb_indicator.set("Все показатели")
def _collect_current_selection(self) -> dict:
proj_code = self.window_ref.current_selected_project_code
group_name = self.cmb_group.get()
group_code = self.group_names_to_codes.get(group_name, "") if group_name else ""
indicator_name = self.cmb_indicator.get()
indicator_code = ""
if indicator_name and indicator_name != "Все показатели" and group_code:
indicators_list = self.indicator_cache.get(group_code, [])
try:
idx = [item[0] for item in indicators_list].index(indicator_name)
indicator_code = f"{group_code}_{idx + 1}"
except ValueError:
pass
return {
"project_code": proj_code,
"group_name": group_name,
"group_code": group_code,
"indicator_name": indicator_name,
"indicator_code": indicator_code
}
def _add_event(self):
data = self._collect_current_selection()
if not all([data['project_code'], data['group_code'], data['indicator_code']]):
msg_parts = []
if not data['project_code']: msg_parts.append("Проект")
if not data['group_code']: msg_parts.append("Группа")
if not data['indicator_code']: msg_parts.append("Показатель")
messagebox.showwarning("Ошибка", f"Заполнены не все обязательные поля.\nОтсутствуют: {', '.join(msg_parts)}")
return
text = self.txt_event.get("1.0", "end").strip()
if not text:
messagebox.showwarning("Ошибка", "Поле 'Содержание события' пустое.")
return
date_str = self.date_entry.get()
sign = self.dir_var.get()
success, msg = self.logic.add_event_to_db(data, text, date_str, sign)
messagebox.showinfo("Результат", msg)
if success:
self.txt_event.delete("1.0", "end")
proj_code = self.window_ref.current_selected_project_code
self.cmb_group.set("Все группы")
self.cmb_indicator.set("Все показатели")
self._refresh_event_table(proj_code)
def _refresh_event_table(self, project_code: str = None):
# Очистка таблицы
for row in self.tree.get_children():
self.tree.delete(row)
if not project_code:
return
db_path = os.path.join(self.logic.project_dbs_folder, f"{project_code}.db")
if not os.path.exists(db_path):
return
try:
conn = sqlite3.connect(db_path)
cur = conn.cursor()
query = "SELECT event_code, event_data, event_sign, event_text_content FROM project_data"
params = ()
conditions = []
current_group = self.cmb_group.get()
current_indicator = self.cmb_indicator.get()
if current_group and current_group != "Все группы":
group_code = self.group_names_to_codes.get(current_group, "")
if group_code:
conditions.append("group_code = ?")
params += (group_code,)
if current_indicator and current_indicator != "Все показатели":
conditions.append("indicator_name = ?")
params += (current_indicator,)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY id DESC LIMIT 50"
print(f"[SQL EXEC] Query: {query} | Params: {params}")
cur.execute(query, params)
rows = cur.fetchall()
conn.close()
print(f"[TABLE DEBUG] Fetched {len(rows)} items from disk.")
# Предварительная очистка выделения перед заполнением новой таблицей
self.tree.selection_remove(self.tree.selection())
# Вставка строк с привязкой клика к полю txt_event
for idx, row in enumerate(rows, start=1):
sign_char = "+" if row[2] == 1 else "-"
full_text = row[3]
iid = f"row_{idx}"
self.tree.insert("", "end", iid=iid, values=(idx, row[0], row[1], sign_char, full_text))
except Exception as e:
print(f"[CRITICAL TABLE FAIL]: {e}")
def _on_tree_select(self, event):
"""
Срабатывает ПРИ ЛЮБОМ ВЫБОРЕ СТРОКИ.
Заполняет форму данными выбранной строки.
"""
selected = self.tree.selection()
if not selected:
return
item_id = selected[0]
vals = self.tree.item(item_id)["values"]
if len(vals) < 5:
return
text_content = vals[4]
# Блокируем модифицированные события, чтобы избежать зацикливания
self.txt_event.unbind("<<Modified>>")
# Очищаем и заполняем поле текстом из таблицы
self.txt_event.delete("1.0", "end")
self.txt_event.insert("1.0", text_content)
# Заполняем дату и знак (+/-)
try:
date_obj = datetime.datetime.strptime(str(vals[2]), '%Y-%m-%d')
self.date_entry.set_date(date_obj)
except Exception:
pass
sign_val = int(vals[3].replace('+', '1').replace('-', '-1'))
self.dir_var.set(sign_val)
# Устанавливаем режим редактирования для этой строки
self.editing_event_code = vals[1]
# Включаем визуальную индикацию (желтый фон удален согласно ТЗ по очистке NativeToolTip)
self._set_edit_mode_visuals(True)
# Возвращаем отслеживание изменений текста
self.after(100, lambda: self.txt_event.bind("<<Modified>>", self._on_text_modified))
def _set_edit_mode_visuals(self, is_active: bool):
"""Меняет цвет фона поля текста и состояние кнопок"""
color = "#FFF9C4" if is_active else "#F0F0F0" # Желтый или стандартный
self.txt_event.configure(background=color)
# Кнопки согласно ТЗ: Редактировать блокируется, Сохранить активна
self.btn_edit.configure(state="disabled")
self.btn_save.configure(state="normal")
# Разблокируем поля даты и знака для изменения
self.date_entry.config(state="normal")
def _get_text_by_iid(self, item_id: str) -> str:
vals = self.tree.item(item_id)["values"]
if len(vals) > 4:
return vals[4]
return ""
def _enter_edit_mode(self):
sel = self.tree.selection()
if not sel:
messagebox.showwarning("Ошибка", "Выберите событие в таблице."); return
self._enter_edit_mode_by_iid(sel[0])
def _enter_edit_mode_by_iid(self, item_id: str):
vals = self.tree.item(item_id)["values"]
if len(vals) < 5:
return
self.editing_event_code = vals[1]
# Заполняем поля формы данными выбранной строки
self.txt_event.delete("1.0", "end")
self.txt_event.insert("1.0", vals[4])
# Дата события
try:
date_obj = datetime.datetime.strptime(str(vals[2]), '%Y-%m-%d')
self.date_entry.set_date(date_obj)
except Exception:
pass
# Направленность (+/-)
sign_val = int(vals[3].replace('+', '1').replace('-', '-1'))
self.dir_var.set(sign_val)
# === РЕЖИМ РЕДАКТИРОВАНИЯ (КНОПКИ И ПОЛЯ) ===
# Блокируем неизменяемые фильтры
self.btn_add.configure(state="disabled")
self.btn_del.configure(state="disabled")
self.cmb_group.configure(state="disabled")
self.cmb_indicator.configure(state="disabled")
# Кнопка Редактировать становится серой (заблокирована)
self.btn_edit.configure(state="disabled")
# Кнопка Сохранить остается активной (оранжевой), как вы просили НЕ скрывать её
# Если она была disabled - включаем
self.btn_save.configure(state="normal")
# Разблокируем поля ввода для изменений (ТЗ п.3)
self.txt_event.configure(state="normal")
self.date_entry.config(state="normal")
# Радиокнопки dir_var обычно доступны всегда, если frame активен
def _save_edited_event(self):
if not getattr(self, 'editing_event_code', None):
return
new_text = self.txt_event.get("1.0", "end").strip()
if not new_text:
# Используем тот же messagebox, который мы только что импортировали
messagebox.showwarning("Ошибка", "Текст не может быть пустым.")
return
try:
date_str = self.date_entry.get_date().strftime('%Y-%m-%d')
except Exception:
date_str = datetime.datetime.now().strftime('%Y-%m-%d')
sign_val = self.dir_var.get()
db_path = os.path.join(self.logic.project_dbs_folder, f"{self._collect_current_selection()['project_code']}.db")
try:
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute(
'''UPDATE project_data
SET event_text_content = ?, event_data = ?, event_sign = ?
WHERE event_code = ?''',
(new_text, date_str, sign_val, self.editing_event_code)
)
conn.commit()
conn.close()
# Если дошли до сюда без ошибок — показываем успех
messagebox.showinfo("Готово", "Изменения сохранены.")
self._reset_controls()
proj_code = self._collect_current_selection()['project_code']
self._refresh_event_table(proj_code)
except Exception as e:
# Если упало подключение к БД или SQL-запрос — показываем ошибку
messagebox.showerror("Ошибка записи", f"Не удалось сохранить изменения: {e}")
def _delete_selected_event(self):
sel = self.tree.selection()
if not sel: return
if not messagebox.askyesno("Подтверждение", "Удалить выбранное событие?"): return
event_code = self.tree.item(sel[0])["values"][1]
db_path = os.path.join(self.logic.project_dbs_folder, f"{self._collect_current_selection()['project_code']}.db")
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("DELETE FROM project_data WHERE event_code = ?", (event_code,))
conn.commit()
conn.close()
self._refresh_event_table(self._collect_current_selection()['project_code'])
def _reset_controls(self):
self.editing_event_code = None
# Возвращаем базовые состояния кнопок
self.btn_add.configure(state="normal")
self.btn_del.configure(state="normal")
self.cmb_group.configure(state="readonly")
self.cmb_indicator.configure(state="readonly")
# Кнопка Редактировать снова зеленая и активная
self.btn_edit.configure(state="normal")
# Кнопка Сохранить гаснет (становится недоступной)
self.btn_save.configure(state="disabled")
# Очищаем поле текста
self.txt_event.delete("1.0", "end")
def _paste_from_clipboard(self):
try:
txt = self.clipboard_get()
self.txt_event.insert(tk.INSERT, txt)
except tk.TclError:
pass
def _bind_global_events(self):
self.txt_event.bind("<Button-3>", lambda e: (self._paste_from_clipboard(), "break"))
def _search_by_code(self):
target = self.search_entry.get().strip()
if not target:
messagebox.showwarning("Поиск", "Введите код."); return
items = self.tree.get_children()
found = None
for item in items:
vals = self.tree.item(item)["values"]
if len(vals) > 1 and vals[1] == target:
found = item
break
if found:
self.tree.selection_set(found)
self.tree.see(found)
self.tree.focus(found)
else:
messagebox.showinfo("Поиск", "Событие с таким кодом не найдено.")
# =================================================================
# =================================================================
# dukora/modules/project/project_event_logic.py
# =================================================================
import os
import sqlite3
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 FROM indicators WHERE group_code = ? ORDER BY name", (group_code,))
indicators = [(row[0], ) for row in cursor.fetchall()]
conn.close()
return indicators
except Exception as e:
print(f"[LOGIC ERROR] Failed to read indicators: {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/project_create_ui.py
# ===================================================
import customtkinter as ctk
from tkinter import ttk
import datetime
from project_create_logic import ProjectCreateLogic
class ProjectCreateUI(ctk.CTkFrame):
EDIT_MODE_COLOR = "#8B4513" # Коричневый для кнопки Сохранить
VIEW_MODE_EDIT_BTN_COLOR = "#10B981" # Зеленый для Редактировать
def __init__(self, master, logic: ProjectCreateLogic):
super().__init__(master)
self.logic = logic
self._current_editing_id = None
self._setup_widgets()
self._load_projects()
self._set_view_mode()
def _setup_widgets(self):
padding = {"padx": 10, "pady": 5}
# Поля ввода (Шапка)
entry_frame = ctk.CTkFrame(self)
entry_frame.pack(fill="x", **padding)
self.name_entry = ctk.CTkEntry(entry_frame, placeholder_text="Название проекта:")
self.name_entry.grid(row=0, column=0, sticky="ew", **padding)
self.short_code_entry = ctk.CTkEntry(entry_frame, placeholder_text="Шифр (проекта):")
self.short_code_entry.grid(row=0, column=1, sticky="ew", **padding)
self.code_entry = ctk.CTkEntry(entry_frame, placeholder_text="Код (ID):")
self.code_entry.grid(row=0, column=2, sticky="ew", **padding)
self.date_entry = ctk.CTkEntry(entry_frame, placeholder_text="Дата открытия:")
today = datetime.datetime.now().strftime("%Y-%m-%d")
self.date_entry.insert(0, today)
self.date_entry.grid(row=1, column=0, sticky="ew", **padding)
self.resp_entry = ctk.CTkEntry(entry_frame, placeholder_text="Ответственный:")
self.resp_entry.grid(row=1, column=1, sticky="ew", **padding)
# Кнопки действий над шапкой
action_frame = ctk.CTkFrame(self)
action_frame.pack(fill="x", **padding)
self.edit_btn = ctk.CTkButton(action_frame, text="Редактировать", fg_color=self.VIEW_MODE_EDIT_BTN_COLOR, command=self._enter_edit_mode)
self.edit_btn.pack(side="left", expand=True, fill="x", padx=(0, 5))
self.save_btn = ctk.CTkButton(action_frame, text="Сохранить", state="disabled", fg_color=self.EDIT_MODE_COLOR, command=self._save_changes)
self.save_btn.pack(side="left", expand=True, fill="x", padx=(5, 0))
# Список существующих проектов
tree_frame = ctk.CTkFrame(self)
tree_frame.pack(fill="both", expand=True, **padding)
columns = ("id", "name", "code", "desc", "date", "resp")
self.tree = ttk.Treeview(tree_frame, columns=columns, show="headings", height=6)
self.tree.heading("name", text="Название"); self.tree.column("name", width=150)
self.tree.heading("code", text="Код"); self.tree.column("code", width=80)
self.tree.heading("date", text="Дата"); self.tree.column("date", width=80)
self.tree.pack(fill="both", expand=True, side="left")
scrollbar = ctk.CTkScrollbar(tree_frame, orientation="vertical", command=self.tree.yview)
scrollbar.pack(side="right", fill="y")
self.tree.configure(yscrollcommand=scrollbar.set)
def _load_projects(self):
for row in self.tree.get_children(): self.tree.delete(row)
for proj in self.logic.manager_db.list_projects():
# Получаем полные данные для отображения в таблице
with self.logic.manager_db.connect() as conn:
cur = conn.execute("SELECT * FROM projects WHERE id = ?", (proj[0],))
full_row = cur.fetchone()
if full_row:
self.tree.insert("", "end", values=(
full_row["id"], full_row["name"], full_row["code"],
full_row["description"], full_row["date_opened"], full_row["responsible"]
))
def _clear_entries(self):
widgets = [self.name_entry, self.short_code_entry, self.code_entry, self.date_entry, self.resp_entry]
for w in widgets: w.delete(0, "end")
def _set_view_mode(self):
widgets = [self.name_entry, self.short_code_entry, self.code_entry, self.date_entry, self.resp_entry]
for w in widgets: w.configure(state="disabled")
self.edit_btn.configure(state="normal")
self.save_btn.configure(state="disabled")
def _enter_edit_mode(self):
selected = self.tree.selection()
if selected:
item = self.tree.item(selected[0])
vals = item["values"]
self._current_editing_id = vals[0]
self.name_entry.configure(state="normal"); self.name_entry.delete(0, "end"); self.name_entry.insert(0, vals[1])
self.short_code_entry.configure(state="normal"); self.short_code_entry.delete(0, "end"); self.short_code_entry.insert(0, vals[2])
# Код проекта менять нельзя
self.code_entry.configure(state="disabled"); self.code_entry.delete(0, "end"); self.code_entry.insert(0, vals[2])
self.date_entry.configure(state="normal"); self.date_entry.delete(0, "end"); self.date_entry.insert(0, vals[4])
self.resp_entry.configure(state="normal"); self.resp_entry.delete(0, "end"); self.resp_entry.insert(0, vals[5])
else:
self._current_editing_id = None
for w in [self.name_entry, self.short_code_entry, self.code_entry, self.date_entry, self.resp_entry]:
w.configure(state="normal")
w.delete(0, "end")
self.edit_btn.configure(state="disabled")
self.save_btn.configure(state="normal")
def _save_changes(self):
data = {
'human_name': self.name_entry.get(),
'short_code': self.short_code_entry.get(),
'full_code': self.code_entry.get(),
'description': "", # В ТЗ нет отдельного поля описания в UI, оставляем пустым
'date': self.date_entry.get(),
'responsible': self.resp_entry.get()
}
if self._current_editing_id:
# Логика обновления существующей записи (если потребуется расширить функционал)
pass
else:
success, msg = self.logic.create_project(data)
messagebox.showinfo("Успех", msg) if success else messagebox.showerror("Ошибка", msg)
self._load_projects()
self._clear_entries()
self._set_view_mode()
# =================================================================
# =====================================================
# dukora/modules/project/project_create_logic.py
# =====================================================
import datetime
import os
import sys
import sqlite3
from tkinter import messagebox
from modules.data.project_database import ProjectDatabase
from typing import Optional, Tuple, Dict, List
class ProjectCreateLogic:
MIN_CODE_LENGTH = 3
def __init__(self, manager_db: ProjectDatabase):
self.manager_db = manager_db
self.project_dbs_folder = os.path.join(os.getcwd(), "project_dbs")
os.makedirs(self.project_dbs_folder, exist_ok=True)
def validate_inputs(self, human_name: str, short_code: str, full_code: str) -> tuple[bool, str]:
if not human_name or not short_code or not full_code:
return False, "Все поля шапки обязательны для заполнения."
if len(full_code) < self.MIN_CODE_LENGTH:
return False, f"Код проекта должен быть длиннее {self.MIN_CODE_LENGTH} символов."
return True, ""
def check_project_exists(self, code: str) -> bool:
"""Проверяет существование записи в общем списке И файла индивидуальной БД."""
exists_in_list = any(p[2] == code for p in self.manager_db.list_projects())
file_exists = os.path.exists(os.path.join(self.project_dbs_folder, f"{code}.db"))
return exists_in_list or file_exists
def suppress_stdout(func):
"""Декоратор для подавления вывода print при инициализации ProjectDatabase."""
def wrapper(*args, **kwargs):
old_stdout = sys.stdout
sys.stdout = open(os.devnull, 'w')
try:
return func(*args, **kwargs)
finally:
sys.stdout.close()
sys.stdout = old_stdout
return wrapper
@suppress_stdout
def initialize_individual_db(self, code: str) -> bool:
"""Создает пустую индивидуальную базу для конкретного проекта."""
db_path = os.path.join(self.project_dbs_folder, f"{code}.db")
if os.path.exists(db_path):
return False
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS project_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_code TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
group_name TEXT,
indicator_name TEXT,
event_data DATE,
event_sign INTEGER,
event_code TEXT,
event_text_content TEXT
)
''')
conn.commit()
conn.close()
return True
def create_project(self, data: dict) -> tuple[bool, str]:
"""
:param data: {'human_name': ..., 'short_code': ..., 'full_code': ..., 'description': ..., 'date': ..., 'responsible': ...}
"""
is_valid, error_msg = self.validate_inputs(
data['human_name'], data['short_code'], data['full_code']
)
if not is_valid:
return False, error_msg
if self.check_project_exists(data['full_code']):
return False, f"База данных или проект с кодом '{data['full_code']}' уже существует."
success = self.manager_db.add_project(
name=data['human_name'],
code=data['full_code'],
cipher=data['short_code'], # <--- ДОБАВИЛИ ПЕРЕДАЧУ ШИФРА
description=data['description'],
date_opened=data['date'],
responsible=data['responsible']
)
if not success:
return False, "Не удалось сохранить данные проекта в общий реестр."
init_success = self.initialize_individual_db(data['full_code'])
if not init_success:
# Если создание индивид. БД провалилось, удаляем запись из общего списка для консистентности
with self.manager_db.connect() as conn:
conn.execute("DELETE FROM projects WHERE code = ?", (data['full_code'],))
return False, "Проект создан в реестре, но не удалось создать рабочую базу данных."
return True, f"Проект '{data['human_name']}' успешно создан!"
# ========================================
# dukora/modules/data/project_database.py
# ========================================
import os
import sqlite3
from pathlib import Path
from typing import Optional, List, Tuple
from types import SimpleNamespace
class ProjectDatabase:
"""
Управляет общей базой данных 'projects.db', хранящей метаданные о проектах.
Создает файл БД в директории db относительно корня проекта.
"""
def __init__(self):
# Используем утилиту получения корня проекта
from modules.utils.resources import get_project_root
self.base_dir = get_project_root() / "db"
self.base_dir.mkdir(parents=True, exist_ok=True)
self.db_path = self.base_dir / "projects.db"
self.connection: Optional[sqlite3.Connection] = None
def connect(self) -> sqlite3.Connection:
if self.connection is None:
try:
self.connection = sqlite3.connect(self.db_path)
self.connection.row_factory = sqlite3.Row
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()
# ДОБАВИЛИ колонку cipher TEXT NOT NULL UNIQUE
cursor.execute('''
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
code TEXT NOT NULL UNIQUE,
cipher TEXT NOT NULL UNIQUE,
description TEXT DEFAULT '',
date_opened TEXT DEFAULT '',
responsible TEXT DEFAULT ''
)
''')
conn.commit()
def add_project(self, name: str, code: str, cipher: str, description: str, date_opened: str, responsible: str) -> bool:
"""Добавляет запись о проекте в общий список."""
try:
with self.connect() as conn:
conn.execute(
"INSERT INTO projects (name, code, cipher, description, date_opened, responsible) VALUES (?, ?, ?, ?, ?, ?)",
(name, code, cipher, description, date_opened, responsible)
)
return True
except sqlite3.IntegrityError:
return False
except Exception:
return False
def list_projects(self) -> List[Tuple]:
"""Возвращает список кортежей (id, name, code, cipher)."""
with self.connect() as conn:
cursor = conn.execute("SELECT id, name, code, cipher FROM projects ORDER BY name")
return cursor.fetchall()
def list_projects_names(self) -> List[str]:
"""Возвращает плоский список только названий проектов для ComboBox."""
with self.connect() as conn:
cursor = conn.execute("SELECT name FROM projects ORDER BY name")
return [row[0] for row in cursor.fetchall()]
def get_all_groups(self):
"""Читает общую таблицу групп."""
try:
with self.connect() as conn:
cursor = conn.cursor()
# Проверяем наличие таблицы перед запросом
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='groups';")
if not cursor.fetchone():
return []
cursor.execute("SELECT id, code, name FROM groups ORDER BY name")
rows = cursor.fetchall()
# Возвращаем объекты SimpleNamespace для совместимости с вашим старым кодом UI
from types import SimpleNamespace
return [SimpleNamespace(id=r[0], code=r[1], name=r[2]) for r in rows]
except Exception as e:
print(f"[DB ERROR] Groups load failed: {e}")
return []
def get_indicators_by_group(self, group_code: str):
"""Читает показатели конкретной группы из общей базы."""
try:
with self.connect() as conn:
cursor = conn.cursor()
# Проверяем наличие таблицы индикаторов
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='indicators';")
if not cursor.fetchone():
return []
cursor.execute("SELECT id, code, name, group_code FROM indicators WHERE group_code = ? ORDER BY name", (group_code,))
rows = cursor.fetchall()
from types import SimpleNamespace
return [SimpleNamespace(id=r[0], code=r[1], name=r[2], group_code=r[3]) for r in rows]
except Exception as e:
print(f"[DB ERROR] Indicators load failed: {e}")
return []
# =================================================================
# ============================================
# dukora/modules/data/database_module.py
# ============================================
import sys
import sqlite3
from pathlib import Path
try:
# Определяем корень проекта надежно:
# Если мы находимся внутри подпапок dukora, идем вверх до корня репозитория.
# __file__ дает путь к текущему .py файлу.
current_file_path = Path(__file__).resolve()
# Ищем папку 'dukora' в родителях
try:
dukora_parent = current_file_path.parents[[p.name for p in current_file_path.parents].index('dukora')]
except ValueError:
# Если по какой-то причине не нашли (запуск из другой директории), берем CWD
dukora_parent = Path.cwd()
BASE_DIR = dukora_parent
DB_FOLDER = BASE_DIR / "db"
ACTIVE_FLAG_PATH = DB_FOLDER / "active.flag"
DEFAULT_DB_PATH = DB_FOLDER / "database.db"
MASTERDATA_PATH = DB_FOLDER / "masterdata.db"
except Exception as e:
print(f"[FATAL] Не удалось вычислить путь к базе данных: {e}")
sys.exit(1)
def _determine_active_path():
try:
if ACTIVE_FLAG_PATH.exists():
with open(ACTIVE_FLAG_PATH, 'r', encoding='utf-8') as f:
name = f.read().strip()
if name == "masterdata":
return MASTERDATA_PATH
except Exception:
pass
return DEFAULT_DB_PATH
DB_PATH = _determine_active_path()
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session as SqlAlchemySession, declarative_base
engine = create_engine(f"sqlite:///{DB_PATH}", echo=False, connect_args={"check_same_thread": False})
Base = declarative_base()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db_session = SessionLocal()
try:
yield db_session
finally:
db_session.close()
def get_db_connection():
active_path = _determine_active_path()
global DB_PATH
DB_PATH = active_path
# === КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ ===
# Сначала проверяем наличие папки и создаем её ДО попытки открыть БД
if not DB_FOLDER.exists():
print(f"[ACTION] Создаю папку для базы: {DB_FOLDER}")
try:
DB_FOLDER.mkdir(parents=True, exist_ok=True)
except PermissionError as pe:
print(f"[FATAL] Нет прав на создание папки '{DB_FOLDER}'. Запустите терминал от имени Администратора.")
raise SystemExit(1)
conn = sqlite3.connect(active_path)
conn.execute("PRAGMA foreign_keys = ON;")
return conn
def _create_tables(cursor):
cursor.execute('''
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
code TEXT NOT NULL UNIQUE,
gain_group REAL DEFAULT 0.0
)
''')
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
)
''')
def _populate_test_data(conn):
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM groups")
count = cursor.fetchone()[0]
if count == 0:
print("[INFO] База данных пуста. Заполняю тестовыми данными...")
groups_data = [("Клубы", "KB", 1.5), ("Фанаты", "FAN", 2.0)]
cursor.executemany("INSERT INTO groups (name, code, gain_group) VALUES (?, ?, ?)", groups_data)
indicators_data = [
("ЦСКА", "KB-1", "KB"), ("Спартак", "KB-2", "KB"), ("Зенит", "KB-3", "KB"),
("Мужчины", "FAN-1", "FAN"), ("Женщины", "FAN-2", "FAN"), ("Дети", "FAN-3", "FAN")
]
cursor.executemany("INSERT INTO indicators (name, code, group_code) VALUES (?, ?, ?)", indicators_data)
conn.commit()
print("[SUCCESS] Тестовые данные успешно записаны.")
else:
print("[INFO] База данных уже содержит данные. Пропускаю инициализацию.")
def init_database():
print("[INIT] Проверка наличия рабочих баз данных...")
conn = None
try:
conn = get_db_connection()
cursor = conn.cursor()
_create_tables(cursor)
_populate_test_data(conn)
except sqlite3.Error as e:
print(f"[ERROR] Ошибка при инициализации DATABASE.DB: {e}")
if conn:
conn.rollback()
finally:
if conn:
conn.close()
master_conn = None
try:
if not MASTERDATA_PATH.exists():
print(f"[ACTION] База мастер-данных не найдена. Создаю новую: {MASTERDATA_PATH.name}")
master_conn = sqlite3.connect(MASTERDATA_PATH)
master_conn.execute("PRAGMA foreign_keys = ON;")
master_cursor = master_conn.cursor()
_create_tables(master_cursor)
_populate_test_data(master_conn)
except sqlite3.Error as e:
print(f"[ERROR] Ошибка при инициализации MASTERDATA.DB: {e}")
if master_conn:
master_conn.rollback()
finally:
if master_conn:
master_conn.close()
class Session:
def __init__(self):
pass
# --- ФУНКЦИИ ДЛЯ ИМПОРТА (лежат строго вне класса Session) ---
def list_groups():
"""Функция-заглушка для прямого импорта"""
return [(1, "Клубы"), (2, "Фанаты")]
def get_all_indicators_by_group(group_code):
"""Функция-заглушка для прямого импорта"""
mock_data = {
"KB": [(1, "ЦСКА"), (2, "Спартак"), (3, "Зенит")],
"FAN": [(4, "Мужчины"), (5, "Женщины"), (6, "Дети")]
}
return mock_data.get(group_code, [])
if __name__ == "__main__":
init_database()
# =================================================================
#====================================================
# dukora\gui\main_interface.py
#====================================================
import os
import customtkinter as ctk
from PIL import Image
import sys
from modules.manager.manager_window import ManagerWindow
from modules.experts.experts_window import ExpertsWindow
from modules.project.project_window import ProjectWindow
from modules.forecasting.forecast_window import ForecastWindow
from modules.utils.resources import resource_path
from typing import List, Any
class MainApp(ctk.CTk):
def __init__(self):
super().__init__()
self.title("Dozor System")
try:
if sys.platform == "win32":
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
target_w, target_h = 422, 335
self.geometry(f"{target_w}x{target_h}")
self.resizable(False, False)
bg_color = "#F0F0F0"
self.configure(fg_color=bg_color)
main_bg = ctk.CTkFrame(self, fg_color=bg_color)
main_bg.pack(fill="both", expand=True, padx=1, pady=1)
# === СЕТКА ИНТЕРФЕЙСА ===
main_bg.grid_columnconfigure(0, minsize=210, weight=0)
main_bg.grid_columnconfigure(1, weight=1)
for i in range(6):
main_bg.grid_rowconfigure(i, weight=0)
main_bg.grid_rowconfigure(6, weight=1)
button_font = ("Arial", 13, "bold")
btn_height = 45
# === ЛЕВАЯ ПАНЕЛЬ МЕНЮ (КНОПКИ ВЫЗОВА) ===
sidebar = ctk.CTkFrame(main_bg, fg_color="#EEEEEE", width=210)
sidebar.grid(row=0, column=0, rowspan=7, sticky="nsew", padx=(5, 5), pady=5)
sidebar.grid_columnconfigure(0, weight=1)
actions = [
("Администратор", self.open_admin_module),
("Менеджер базы", self.open_manager_module),
("Эксперты", self.open_experts_module),
("Проект", self.open_project_module),
("Прогнозирование", self.open_forecasting_module)
]
# Создание физических кнопок на экране
for i, (text, command_func) in enumerate(actions):
btn = ctk.CTkButton(
master=sidebar,
text=text,
font=button_font,
height=btn_height,
command=command_func
)
btn.grid(row=i, column=0, padx=10, pady=5, sticky="ew")
# === ПРАВАЯ ЧАСТЬ ВИТРИНЫ ===
content_area = ctk.CTkFrame(main_bg, fg_color=bg_color)
content_area.grid(row=0, column=1, rowspan=7, sticky="nsew", padx=(0, 10), pady=10)
content_area.grid_columnconfigure(0, weight=1)
content_area.grid_rowconfigure(0, weight=0)
content_area.grid_rowconfigure(1, minsize=2, weight=0)
content_area.grid_rowconfigure(2, minsize=27, weight=0)
content_area.grid_rowconfigure(3, weight=0)
pic_container = ctk.CTkFrame(content_area, fg_color="#d9ead3", width=220, height=130)
pic_container.grid(row=0, column=0, pady=(5, 0))
pic_container.grid_propagate(False)
img_loaded = False
try:
img_path = resource_path('images/logo1.png')
pil_img = Image.open(img_path)
target_w_i, target_h_i = 210, 110
ratio = pil_img.width / pil_img.height
box_ratio = target_w_i / target_h_i
if ratio > box_ratio:
new_w, new_h = target_w_i, int(target_w_i / ratio)
else:
new_w, new_h = int(target_h_i * ratio), target_h_i
resized_img = pil_img.resize((new_w, new_h), resample=Image.LANCZOS)
tk_image = ctk.CTkImage(light_image=resized_img, size=(new_w, new_h))
lbl_pic = ctk.CTkLabel(pic_container, image=tk_image, text="", fg_color="#d9ead3")
lbl_pic.place(relx=0.5, rely=0.5, anchor='center', x=0, y=-3)
img_loaded = True
except Exception as e:
fail_lbl = ctk.CTkLabel(pic_container, text="Сова\nне найдена", font=("Arial", 14), fg_color="#d9ead3")
fail_lbl.place(relx=0.5, rely=0.5, anchor="center")
bottom_btns_frame = ctk.CTkFrame(content_area, fg_color=bg_color)
bottom_btns_frame.grid(row=3, column=0, sticky="ew", padx=0)
bottom_btns_frame.grid_columnconfigure(0, minsize=195, weight=0)
ctk.CTkButton(
bottom_btns_frame,
text="Руководство",
font=button_font,
height=btn_height,
command=self.open_guide
).grid(row=0, column=0, sticky="ew", pady=(0, 8))
ctk.CTkButton(
bottom_btns_frame,
text="О программе",
font=button_font,
height=btn_height,
command=self.show_about
).grid(row=1, column=0, sticky="ew")
footer_frame = ctk.CTkFrame(main_bg, fg_color="#0078D7", height=30)
footer_frame.grid(row=7, column=0, columnspan=2, sticky="ew", pady=(10, 0))
footer_label = ctk.CTkLabel(footer_frame,
text="Copyright © Минск, НИИ, 2027 год",
text_color="#FF8C00",
fg_color="#0078D7",
font=("Arial", 13, "bold"))
footer_label.pack(expand=True, ipady=4)
self.update_idletasks()
self.tk.call('wm', 'geometry', self._w, f"{target_w}x{target_h}")
screen_w = self.winfo_screenwidth()
# Высота верхней панели задач Windows обычно около 40 пикселей.
# Если панель скрыта или находится сбоку/снизу, отступ можно уменьшить до 0-10.
taskbar_offset_top = 40
pos_x = (screen_w - target_w) // 2
pos_y = taskbar_offset_top
# Устанавливаем размер И позицию одной командой через configure
self.geometry(f"{target_w}x{target_h}+{pos_x}+{pos_y}")
def _position_center_top(self, window_instance, width: int, height: int):
screen_w = window_instance.winfo_screenwidth()
pos_x = (screen_w - width) // 2
pos_y = 40 # тот же отступ сверху
window_instance.geometry(f"{width}x{height}+{pos_x}+{pos_y}")
# === ОБРАБОТЧИК КНОПКИ АДМИНИСТРАТОРА (с принудительным логом) ===
def open_admin_module(self):
if hasattr(self, '_admin_win') and self._admin_win.winfo_exists():
self._admin_win.lift()
return
try:
from modules.admin.admin_accordion_window import AdminAccordionWindow
self._admin_win = AdminAccordionWindow(self)
self.wait_window(self._admin_win)
except Exception as e:
messagebox.showerror("Фатальный сбой", f"Не удалось открыть раздел Администратора.\n\n{e}")
import traceback
traceback.print_exc()
def open_manager_module(self):
if hasattr(self, '_manager_win') and self._manager_win.winfo_exists():
self._manager_win.lift()
return
self._manager_win = ManagerWindow(master=self)
self.wait_window(self._manager_win)
def open_experts_module(self):
if hasattr(self, '_experts_win') and self._experts_win.winfo_exists():
self._experts_win.lift()
return
try:
from modules.experts.experts_window import ExpertsWindow
self._experts_win = ExpertsWindow(master=self)
self.wait_window(self._experts_win)
except Exception as e:
messagebox.showerror("Фатальный сбой", f"Не удалось открыть раздел Экспертов.\n\n{e}")
import traceback
traceback.print_exc()
def open_project_module(self):
"""Открывает окно управления проектами."""
if hasattr(self, '_project_win') and self._project_win.winfo_exists():
self._project_win.lift()
return
self._project_win = ProjectWindow(master=self)
try:
# Сбрасываем кэш транзакций
self._project_win.manager_db.close()
# Читаем ВСЕ проекты из мастер-базы
# Кортеж имеет вид: (id, Name, Code, Cipher)
fresh_projects = self._project_win.manager_db.list_projects()
# === ИСПРАВЛЕНИЕ 1: БЕРЕМ ДЛЯ ОТОБРАЖЕНИЯ ШИФР (индекс 3) ===
display_names = [p[3] for p in fresh_projects]
print(f"[DEBUG MAIN MENU] Final display list: {display_names}")
if hasattr(self._project_win, 'project_selector'):
self._project_win.project_selector.configure(values=display_names)
if display_names:
selected_display = display_names[-1]
# Устанавливаем текст в поле (пользователь видит Шифр)
self._project_win.project_selector.set(selected_display)
# === ИСПРАВЛЕНИЕ 2: НАХОДИМ КОД ПО ПРАВИЛЬНОМУ ШИФРУ ===
real_code = next((p[2] for p in fresh_projects if p[3] == selected_display), "")
self._project_win.current_selected_project_code = real_code
print(f"[SYNC] UI shows '{selected_display}', internal code set to '{real_code}'")
else:
self._project_win.project_selector.set("Проектов нет")
self._project_win.current_selected_project_code = ""
# Обновляем таблицу-отладку внизу окна (оставляем там всё для наглядности)
if hasattr(self._project_win, 'tree_projects'):
tree = self._project_win.tree_projects
for row in tree.get_children():
tree.delete(row)
for proj in sorted(fresh_projects, key=lambda x: x[0]):
tree.insert("", "end", values=proj)
except Exception as e:
print(f"[ERROR] Failed to sync projects on module open: {e}")
if hasattr(self._project_win, 'project_selector'):
self._project_win.project_selector.configure(values=[])
self._project_win.project_selector.set("Проектов нет")
self._project_win.current_selected_project_code = ""
self._project_win.grab_set()
def open_forecasting_module(self):
if hasattr(self, '_forecast_win') and self._forecast_win.winfo_exists():
self._forecast_win.lift()
return
self._forecast_win = ForecastWindow(master=self)
self.wait_window(self._forecast_win)
def open_guide(self):
file_name = "manual.chm"
full_path = resource_path(file_name)
print(f"Пытаюсь открыть: {full_path}")
if os.path.exists(full_path):
try:
os.startfile(full_path)
except Exception as e:
print(f"Система отказалась открывать файл: {e}")
else:
print("Ошибка: ФАЙЛА НЕТ в этой папке!")
def show_about(self):
from tkinter import messagebox
info_text = (
"Система Dozor V1.0\n"
"Разработано: Минск, НИИ\n"
"Версия сборки: 31.07.2026\n\n"
"(c) Все права защищены."
)
messagebox.showinfo(title="О программе", message=info_text)
if __name__ == "__main__":
app = MainApp()
app.mainloop()
# =================================================================
#================================================
# 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
# =================================================================