Загрузка данных
Промпт: Завершение реализации окна аккордеона под заголовком "Открыть для ввода событий".
Цель: Полностью отладить функционал второго заголовка аккордеона ("Открыть для ввода событий").
o Задачи по отладке окна
1. В выпадающих списках "Выбрать группу" и "Выбрать показатель" в данный момент все работает идеально и синхронизировано с базой данных (справочником). Необходимо сделать так, чтобы вверху списков добавились фиксированые записи "Все группы", а также "Все показатели" соответственно. Данные записи должны отражаться при заходе на страницу окна по умолчанию, таблица событий должна быть обновлена и в ней должны быть видны все события, при чем последнее записанное событие должно быть всегда при инициализации, обновлении таблицы и добавлении быть на виду. При выборе конкретной группы в списке показателей должно происходить переключение на показатели своей группы (это реализовано).
2. При заходе на страницу "Открыть для ввода событий" в выпадающих списках изначально должно быть выбраны "Все гуппы" и "Все показатели", а в таблице добавленных событий должны быть размещены все подключенные события по дате возрастания. При переключении группы и выборе показателя в таблице должны быть показаны события именно этой группы и именно этого показателя.
3. Для того чтобы события попадали по нужному адресу они должны быть соответственно правильно сохранены. Для этого, когда пользователь вставил (или напечатал) новое событие, то при нажатии на кнопку "Добавить" должна проходить проверка и предупреждение пользователю, чтобы он выбрал группу ("Все группы" выбирать нельзя), после выбора группы пользователь должен выбрать показатель. Показатель должен выбираться явно кликом в списке, о чем должно быть предупреждение при нажатии на "Добавить", чтобы пользователь выбрал показатель.
4. Поле "Содержание событий" сливается с общим фоном окна. Его необходима совсем немного затемнить или выделить рамкой.
5. После добавления события оно должно попадать в общий список , окно таблицы обновляться, списки групп и показателей устанавливаться выбранными как "Все группы" и "Все показатели"
6. Поле "Дата события" должно заполняться через виджет Календаря (русской версии, в данный момент - английская).
7. В таблицу событий необходимо добавить слева поле "№п/п". Данные должны быть всегда по порядку в самой таблице, и пересчитываться после удаления любого события (для красоты).
Поля "№п/п", "Код", "Дата", "+/-" должны быть фиксированной ширины и равняться:
"№п/п" – 4 знака
"Код" – 10 знаков
"Дата" – 11 знаков
"+/-" - 3 знака
8. При клике на событии в таблице данная строчка должна выделяться и всплывать полное название события. Пока мышка на строчке всплывающее событие находится на экране. При смещении мышки через секунду событие закрывается.
9. Редактирование событий. В данный момент в окне 4 основных кнопки. Необходимо сделать чтобы в окне было 3 основных кнопки - одна, в зависимости от режима должна быть скрыта. Стандартный режим: в окне кнопки: "Добавить", "Редактировать", "Удалить". При нажатии на "Удалить" кнопка "Редактировать" прячется, а на ее месте появляется кнопка "Сохранить". Все поля переходят в режим редактирования и могут быть изменены. После нажатия на кнопку "Сохранить" - происходит сохранение изменений кнопка "Сохранить" прячется и опять появляется кнопка "Редактировать" - окно выходит из режима редактирования. Изменять пользователь может только текст события, дату, направленность и удаление, остальные поля должны быть заблокированы для редактирования.
10. Поиск. Фактически эта опция предназначена для поиска события для дальнейшего редактирования или удаления. Чтобы найти событие пользователь должен набрать код события и нажать на кнопку "Искать". Найденное событие должно быть в видимой части экрана в таблице и код залит желтым цветом.
o Список представленных файлов
Чтобы провести отладку, даю актуальное содержимое следующих файлов целиком:
1. Интерфейс формы событий: modules/project/project_event_ui.py
2. Бизнес-логика чтения/записи: modules/project/project_event_logic.py
3. Главный координатор (аккордеон): modules/project/project_window.py
4. Менеджер БД проектов: modules/data/project_database.py
5. Единая база справочников: modules/data/database_module.py
6. Точка входа (проверка инициализации мастер-данных): gui/main_interface.py
7. Функция путей: dukora/modules/utils/resources.py
Я, вайб кодер - ты, профессиональный программист. Ты делаешь - я вставляю, заменяю.
Кусками не работаем - только целыми фрагментами - метод на метод, файл на файл, от комментария до комментария, от комментария до конца и т.д. - по такому принципу.
Делаем отладку в той последовательности, как в ТЗ. Начинаем с добавления строчек в выпадающие списки.
#==============================================================================
#======================================================
# dukora/modules/project/project_event_ui.py
#======================================================
import customtkinter as ctk
from tkinter import ttk, messagebox
import datetime
import tkinter as tk
import os
import sqlite3
from .project_event_logic import ProjectEventLogic
from tkcalendar import DateEntry
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
# FINAL FIX: Храним только чистые списки строк для надежной работы ComboBox
self.group_names_list = []
self.indicator_names_list = []
self.group_names_to_codes = {}
self.indicator_cache = {} # {group_code: [IndicatorDTO objects]}
self._setup_widgets()
self._bind_global_events()
# Загружаем данные сразу. Если БД жива - увидим их мгновенно.
self.update_project_dependent_lists()
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)
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))
self.txt_event = ctk.CTkTextbox(main_form, height=80, wrap="word")
self.txt_event.grid(row=2, column=1, sticky="ew", pady=(0, 5))
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))
from tkcalendar import DateEntry
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 = "clam" if "dark" in ctk.get_appearance_mode() else "default"
style.theme_use(theme_name)
columns = ("code", "date", "sign", "event")
self.tree = ttk.Treeview(tree_container, columns=columns, show="headings", selectmode="browse", height=3)
self.tree.heading("code", text="Код"); self.tree.column("code", width=80, anchor="center")
self.tree.heading("date", text="Дата"); self.tree.column("date", width=90, anchor="center")
self.tree.heading("sign", text="+/-"); self.tree.column("sign", width=45, anchor="center")
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):
"""Глобальное обновление списков Группа/Показатель."""
try:
if not hasattr(self, 'window_ref') or not self.window_ref:
print("[UI ERROR] window_ref is missing.")
return
selected_project_name = self.window_ref.project_selector.get()
print(f"[UI SYNC] Updating for project: {selected_project_name}")
groups_raw, name_to_code_map = self.logic.load_groups_and_indicators()
if not groups_raw:
print("[UI WARNING] Groups list is empty from logic.")
group_names = ["Нет групп"]
else:
group_names = [str(g).strip() for g in groups_raw]
# Обновляем кэш имен
self.group_names_list = group_names
self.group_names_to_codes = name_to_code_map
# Принудительное обновление виджета Группы
self.cmb_group.configure(values=[])
self.cmb_group.configure(values=group_names)
if len(group_names) > 0 and group_names[0] != "Ошибка БД":
self.cmb_group.set(group_names[0])
# Сразу загружаем показатели для выбранной группы
first_group_name = group_names[0]
indicators = self.logic.load_indicators_for_group(self.group_names_to_codes.get(first_group_name, ""))
ind_names = [ind[0] for ind in indicators]
self.indicator_names_list = ind_names
self.cmb_indicator.configure(values=ind_names)
if ind_names:
self.cmb_indicator.set(ind_names[0])
# Сохраняем полные объекты DTO для сопоставления имени -> кода
self.indicator_cache[self.group_names_to_codes.get(first_group_name, "")] = indicators
else:
self.cmb_group.set("")
self.cmb_indicator.configure(values=[])
self.cmb_indicator.set("")
except Exception as e:
print(f"[CRITICAL UI LOAD FAIL]: {e}")
self.cmb_group.configure(values=["Ошибка"])
self.cmb_group.set("Ошибка")
self.cmb_indicator.configure(values=[])
self.cmb_indicator.set("")
def _on_group_change(self, event=None):
group_name = self.cmb_group.get()
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 not in self.group_names_to_codes:
self.cmb_indicator.configure(values=[])
return
group_code = self.group_names_to_codes.get(group_name)
indicators = self.logic.load_indicators_for_group(group_code)
# FIX: Берем первый элемент кортежа [0]
ind_names = [ind[0] for ind in indicators]
self.indicator_names_list = ind_names
self.cmb_indicator.configure(values=ind_names)
if ind_names:
self.cmb_indicator.set(ind_names[0])
self.indicator_cache[group_code] = indicators
# FINAL FIX: Максимально упрощенный сбор данных без лишних поисков
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, "")
indicator_name = self.cmb_indicator.get()
indicator_code = ""
# Генерируем код "Об_1", "Об_2" на основе позиции в выпадающем списке
if indicator_name and group_code:
try:
# Извлекаем чистое имя из кортежа ('Имя',)
clean_name = indicator_name[0] if isinstance(indicator_name, tuple) else indicator_name
if clean_name in self.indicator_names_list:
count = self.indicator_names_list.index(clean_name) + 1
indicator_code = f"{group_code}_{count}"
except Exception as gen_err:
print(f"[CODE GEN ERROR]: {gen_err}")
return {
"project_name": "",
"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()
print(f"[ADD EVENT DEBUG] Data collected: {data}")
# Самая строгая проверка
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")
self._refresh_event_table(data['project_code'])
def _refresh_event_table(self, project_code: str):
for row in self.tree.get_children():
self.tree.delete(row)
db_path = os.path.join(self.logic.project_dbs_folder, f"{project_code}.db")
if not os.path.exists(db_path):
return
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("SELECT event_code, event_data, event_sign, event_text_content FROM project_data ORDER BY id DESC LIMIT 50")
rows = cur.fetchall()
conn.close()
last_iid = None
for row in reversed(rows):
sign_char = "+" if row[2] == 1 else "-"
iid = self.tree.insert("", "end", values=(row[0], row[1], sign_char, row[3]))
last_iid = iid
if last_iid:
self.tree.see(last_iid)
# Остальные методы (_paste_from_clipboard, редактирование...) остаются прежними
# (кроме добавления проверок self._is_refreshing_ui внутрь них при необходимости)
def _paste_from_clipboard(self):
try:
txt = self.clipboard_get()
self.txt_event.insert(tk.INSERT, txt)
except Exception:
pass
def _enter_edit_mode(self):
sel = self.tree.selection()
if not sel:
messagebox.showwarning("Ошибка", "Выберите событие в таблице."); return
vals = self.tree.item(sel[0])["values"]
self.editing_event_code = vals[0]
full_text = vals[3]
self.txt_event.delete("1.0", "end")
self.txt_event.insert("1.0", full_text)
self.btn_add.configure(state="disabled")
self.btn_edit.configure(state="disabled")
self.btn_save.configure(state="normal")
self.btn_del.configure(state="disabled")
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.showwarning("Ошибка", "Текст не может быть пустым."); return
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("UPDATE project_data SET event_text_content = ? WHERE event_code = ?", (new_text, self.editing_event_code))
conn.commit()
conn.close()
messagebox.showinfo("Готово", "Изменения сохранены.")
self._reset_controls()
self._refresh_event_table(self._collect_current_selection()['project_code'])
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"][0]
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 _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:
if self.tree.item(item)["values"][0] == target:
found = item
break
if found:
self.tree.selection_set(found)
self.tree.see(found)
self.tree.focus(found)
else:
messagebox.showinfo("Поиск", "Событие с таким кодом не найдено.")
def _reset_controls(self):
self.btn_add.configure(state="normal")
self.btn_edit.configure(state="normal")
self.btn_save.configure(state="disabled")
self.btn_del.configure(state="normal")
self.txt_event.delete("1.0", "end")
def _bind_global_events(self):
self.txt_event.bind("<Button-3>", lambda e: (self._paste_from_clipboard(), "break"))
#======================================================
#======================================================
# 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()
if is_new_db:
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,
group_code TEXT,
indicator_name TEXT,
indicator_code TEXT,
event_data DATE,
event_sign INTEGER,
event_code TEXT,
event_text_content TEXT
)
''')
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}"
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,
event_code,
event_text
))
conn.commit()
return True, f"Данные добавлены. Код события: {event_code}"
except Exception as e:
conn.rollback()
return False, f"Ошибка записи в БД: {e}"
finally:
conn.close()
#======================================================
#=====================================================
# modules/project/project_window.py
#=====================================================
import customtkinter as ctk
from tkinter import ttk, messagebox
from modules.project.project_event_logic import ProjectEventLogic
from typing import List
import datetime
import os
# Попытка импорта внешних библиотек (оставляем ваши try/except)
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
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)
self._build_project_selector(main_container)
accordion_frame = ctk.CTkFrame(main_container)
accordion_frame.pack(fill="both", expand=True, pady=(10, 0))
self.accordion_data = {}
self.currently_open_button = None
# === ЗАГОЛОВКИ АККОРДЕОНА (ТЕПЕРЬ ИХ 4) ===
create_item = self._create_accordion_header(accordion_frame, "Создать проект")
events_item = self._create_accordion_header(accordion_frame, "Открыть для ввода событий")
charts_item = self._create_accordion_header(accordion_frame, "Показать графики")
print_item = self._create_accordion_header(accordion_frame, "Настройка печати")
# Наполняем секции контентом (ваши методы наполнения остаются прежними)
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))
# Открываем первую новую секцию по умолчанию
self.toggle_section(create_item["button"], create_item["content"])
# === ЗАМЕНА МЕТОДА: КНОПКИ ТЕПЕРЬ ОДНА ("ЗАКРЫТЬ") ===
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)
# === НОВЫЙ МЕТОД ДЛЯ ШАПКИ НАД СЕЛЕКТОРОМ ПРОЕКТА ===
def _build_title_bar(self, container):
title_lbl = ctk.CTkLabel(container, text="Наполнение проекта", font=ctk.CTkFont(size=20, weight="bold"))
# Убран anchor="w". По умолчанию CTkLabel центрирует текст.
title_lbl.pack(pady=(0, 15))
def _build_project_selector(self, container):
"""
Создает блок выбора проекта над аккордеоном без использования placeholder_text.
Логика "Проектов нет" реализована через set() текстового поля.
"""
frame = ctk.CTkFrame(container)
frame.pack(pady=(0, 10))
label_project = ctk.CTkLabel(frame, text="Выбрать проект:", anchor="w")
label_project.pack(side="left", padx=(0, 10))
# Передаем пустой список значений
self.project_selector = ctk.CTkComboBox(
frame,
values=[],
state="readonly",
width=250
)
# Так как списка нет, пишем текст напрямую в поле ввода комбобокса
self.project_selector.set("Проектов нет")
self.project_selector.pack(side="left")
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 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)
# Вызываем ПОСЛЕ установки geometry, чтобы считать актуальные размеры
self.after(50, self._enforce_onscreen_position)
# === ОБНОВЛЕННЫЙ РАСЧЕТ ГЕОМЕТРИИ (УЧИТЫВАЕТ 4 СЕКЦИИ И НИЖНЮЮ ПАНЕЛЬ) ===
def _calculate_window_geometry(self, collapsed=False):
current_width = self.winfo_width()
if current_width == 1:
current_width = 650
header_height = 50 * 4 + 30 # Высота шапок всех 4 секций
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)
# Увеличенный порог видимости всех заголовков (расчет выше показал ~453px)
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 _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)
# --- Поля ввода ---
ttk.Label(form_frame, text="Название:").grid(row=0, column=0, sticky="w", pady=(0, 5), padx=(0, 10))
name_entry = ctk.CTkEntry(form_frame, placeholder_text="Введите полное название...")
name_entry.grid(row=0, column=1, sticky="ew", pady=(0, 5))
ttk.Label(form_frame, text="Шифр:").grid(row=1, column=0, sticky="w", pady=(0, 5), padx=(0, 10))
short_code_entry = ctk.CTkEntry(form_frame, placeholder_text="Краткое имя (латиница)...")
short_code_entry.grid(row=1, column=1, sticky="ew", pady=(0, 5))
ttk.Label(form_frame, text="Код (ID):").grid(row=2, column=0, sticky="w", pady=(0, 5), padx=(0, 10))
code_entry = ctk.CTkEntry(form_frame, placeholder_text="Уникальный идентификатор...")
code_entry.grid(row=2, column=1, sticky="ew", pady=(0, 5))
ttk.Label(form_frame, text="Дата открытия:").grid(row=3, column=0, sticky="w", pady=(0, 5), padx=(0, 10))
date_entry = ctk.CTkEntry(form_frame)
today = datetime.datetime.now().strftime("%Y-%m-%d")
date_entry.insert(0, today)
date_entry.grid(row=3, column=1, sticky="ew", pady=(0, 5))
ttk.Label(form_frame, text="Ответственный:").grid(row=4, column=0, sticky="w", pady=(0, 10), padx=(0, 10))
resp_entry = ctk.CTkEntry(form_frame, placeholder_text="ФИО ответственного лица...")
resp_entry.grid(row=4, column=1, sticky="ew", pady=(0, 10))
# --- Кнопки действий ---
action_frame = ctk.CTkFrame(form_frame, fg_color="transparent")
action_frame.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(5, 0))
action_frame.grid_columnconfigure((0, 1), weight=1) # Равномерное распределение кнопок
create_btn = ctk.CTkButton(
action_frame,
text="Создать проект",
fg_color="#3B82F6",
hover_color="#2563EB",
command=lambda n=name_entry, sc=short_code_entry, c=code_entry, d=date_entry, r=resp_entry: 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: [n.delete(0, "end"), sc.delete(0, "end"), c.delete(0, "end"), r.delete(0, "end")] # Теперь очистка полей сработает
)
)
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: [name_entry.delete(0, "end"), short_code_entry.delete(0, "end"), code_entry.delete(0, "end"), resp_entry.delete(0, "end")]
)
clear_btn.grid(row=0, column=1, sticky="ew", padx=(5, 0))
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()
# --- ПРИНУДИТЕЛЬНАЯ СИНХРОНИЗАЦИЯ ЧЕРЕЗ ОСНОВНОЙ МЕНЕДЖЕР ---
# 1. Закрываем текущее соединение manager_db, чтобы сбросить кэш транзакций
try:
self.manager_db.close()
except Exception:
pass
# 2. Читаем свежие данные ИЗ ОТКРЫТОЙ БАЗЫ ОКНА
fresh_tuples = self.manager_db.list_projects()
print(f"[DEBUG HARD SYNC] Прямое чтение list_projects(): {fresh_tuples}")
# Извлекаем только имена для ComboBox
fresh_names = [p[1] for p in fresh_tuples]
# 3. Радикальное обновление UI (полная очистка + пересоздание с выводом ШИФРА)
parent_widget = self.project_selector.master
# Удаляем старый экземпляр полностью
self.project_selector.destroy()
# Создаем абсолютно новый чистый ComboBox
self.project_selector = ctk.CTkComboBox(
master=parent_widget,
values=fresh_names,
state="readonly",
width=250
)
self.project_selector.pack(side="left")
# TASK: Выводим шифр (код) проекта вместо названия
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)
else:
self.project_selector.set("Проектов нет")
# 4. Обновляем Treeview внизу формы создания
try:
for row in self.tree_projects.get_children():
self.tree_projects.delete(row)
for proj in sorted(fresh_tuples, key=lambda x: x[0]):
self.tree_projects.insert("", "end", values=proj)
except Exception as tree_err:
print(f"[UI ERROR] Treeview refresh failed: {tree_err}")
# 5. Вкладка событий (если открыта)
current_title = ""
if self.currently_open_button:
current_title = self.currently_open_button.cget("text")
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
# ВАШИ СТАРЫЕ МЕТОДЫ НАПОЛНЕНИЯ (_fill_events...) ОСТАЮТСЯ БЕЗ ИЗМЕНЕНИЙ
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, 'event_logic'):
self.event_logic = ProjectEventLogic(self.manager_db)
# --- ДИАГНОСТИКА: Смотрим, что реально приходит из базы ---
print("[DEBUG] Начинаем проверку загрузки справочников...")
groups_raw, names_to_codes = self.event_logic.load_groups_and_indicators()
print(f"[DEBUG RAW GROUPS FROM DB: {groups_raw}")
print(f"[DEBUG MAPPING: {names_to_codes}")
ui = ProjectEventUI(frame, self, self.event_logic)
# Вызываем обновление принудительно
try:
ui.update_project_dependent_lists()
except Exception as e:
print(f"[CRASH UI LOAD]: {e}")
ui.pack(fill="both", expand=True)
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:
pass
for item in list(self.accordion_data.values()):
try:
content = item.get("content")
if content:
content.destroy()
except Exception:
pass
try:
self.destroy()
except Exception:
pass
#=====================================================
#====================================================
# 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()
fresh_projects = self._project_win.manager_db.list_projects()
# Теперь кортежи имеют вид: (id, Name, Code, Cipher)
# Для отображения берем Шифр [3]
display_names = [p[3] for p in fresh_projects]
print(f"[DEBUG MAIN MENU] Projects loaded. Display: {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]
# Устанавливаем текстовое поле комбобокса (ВАЖНО: сначала values, потом set)
self._project_win.project_selector.set(selected_display)
# Сохраняем выбранное значение (Шифр) в атрибут окна для event_ui
self._project_win.current_selected_project_code = selected_display
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
#===================================================