Загрузка данных
Промпт: Завершение реализации «Открыть для ввода событий» и рефакторинг
Цель: Полностью отладить функционал второго заголовка аккордеона («Открыть для ввода событий»), обеспечить стабильную запись данных в индивидуальные базы проектов и очистить проект от мусорных файлов-доноров.
1. Задачи по отладке Кнопки №2 («Открыть для ввода событий»)
Необходимо проверить полный цикл работы пользователя:
• Инициализация UI: При открытии вкладки списки должны быть активны сразу (без клика).
• Смена Группы: При выборе группы из верхнего списка список «Показатель» должен мгновенно очищаться и заполняться соответствующими значениями.
• Кнопки управления:
o Проверить переключение состояний кнопок «Добавить», «Редактировать», «Сохранить», «Удалить». Цвета должны соответствовать (Синяя/Зеленая/Коричневая/Красная).
o Убедиться, что кнопка «Сохранить» появляется только после нажатия на кнопку «Редактировать» и вместо нее и наоборот после сохранения.
• Запись события:
o Нажать «Добавить». Событие должно уйти в файл и отобразиться в таблице в окне.
dukora\project_dbs\{КОД_ПРОЕКТА}.db.
o Поле Дата события должно заполняться через виджет Календаря (русской версии).
o Код Событию присваивается автоматически, он состоит из имеющегося кода выбранного показателя+подчеркивание и очередной номер События при этом показателе. Отсчет начинается с единицы, а не с нуля.
o В таблицу событий добавить слева поле №п/п. Данные должны быть всегда по порядку в самой таблице, пересчитываться после удаления (для красоты).
o Поля №п/п, Код, Дата, +/- должны быть фиксированной ширины и равняться:
№п/п – 4 знака
Код – 10 знаков
Дата – 11 знаков
+/- - 3 знака
o Проверить таблицу под формой: новая строка должна появиться визуально (Treeview или Listbox) без перезагрузки окна.
o Проверить генерацию кода события (формат {ИНДИКАТОР}_{НОМЕР}).
o Проверить редактирование и удаление Событий.
o Проверить вставку событий из буфера обмена кнопкой Вставить, а также кликом правой кнопки мыши в поле для вставки.
o Проверить всплывающее полное наименование события при клике на нем. Оно должно держаться пока наведена мышь и 1 секунду после перемещения мыши.
• Глобальная синхронизация: Если пользователь меняет проект в самом верхнем комбобоксе над аккордеоном, данные в открытой секции «Ввод событий» должны обновиться (подтянуть новые группы этого проекта). В выпадающем списке Выбрать проект, в настоящее время подключается Название проекта. Название проекта необходимо заменить на Шифр проекта
2. Список необходимых файлов для проверки и правки
Чтобы провести отладку, даю актуальное содержимое следующих файлов целиком:
1. Интерфейс формы событий:
o modules/project/project_event_ui.py
2. Бизнес-логика чтения/записи:
o modules/project/project_event_logic.py
3. Главный координатор (аккордеон):
o modules/project/project_window.py (особенно методы _fill_events_section, обработчики выбора проекта сверху — если они там есть).
4. Менеджер БД:
o modules/data/project_database.py
5. Единая база справочников:
o modules/data/database_module.py
6. Точка входа (проверка инициализации мастер-данных):
o gui/main_interface.py (посмотреть вызов init_master_data при старте).
7. Пример организации редактирования на Показателях:
o dukora/modules/manager/manager_metrics_work.py
o dukora\modules\manager\manager_metrics_panel.py
Я, вайб кодер - ты, профессиональный программист. Ты делаешь - я вставляю, заменяю.
Кусками не работаем - только целыми фрагментами - метод на метод, файл на файл, от комментария до комментария, от комментария до конца и т.д. - по такому принципу.
#==============================================================================
#==============================================================================
# dukora/modules/project/project_event_ui.py
#==============================================================================
import customtkinter as ctk
from tkinter import ttk, messagebox
import datetime
import tkinter as tk
from .project_event_logic import ProjectEventLogic
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_to_codes = {}
self.indicator_cache = {}
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))
self.date_entry = ctk.CTkEntry(meta_frame, width=120)
today = datetime.datetime.now().strftime("%Y-%m-%d")
self.date_entry.insert(0, 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):
"""Глобальное обновление списков Группа/Показатель."""
# Проверка 1: Существует ли ссылка на главное окно?
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}")
# --- ЗАГРУЗКА ДАННЫХ ---
try:
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]
except Exception as e:
print(f"[CRITICAL LOGIC FAIL]: {e}")
group_names = ["Ошибка БД"]
name_to_code_map = {}
# --- ПРИНУДИТЕЛЬНОЕ ОБНОВЛЕНИЕ ВИДЖЕТА ГРУПП ---
try:
# Сначала очищаем кэш значений CTkComboBox
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])
else:
self.cmb_group.set("")
except Exception as ui_err:
print(f"[UI CRITICAL] Failed to configure Group ComboBox: {ui_err}")
# Сохраняем словарь для поиска кода группы по имени
self.group_names_to_codes = name_to_code_map
# --- АВТОМАТИЧЕСКОЕ ОБНОВЛЕНИЕ ПОКАЗАТЕЛЕЙ ---
# Вызываем принудительно для первой группы в списке
first_group = ""
if group_names and group_names[0] not in ("Нет групп", "Ошибка БД"):
first_group = group_names[0]
self._update_indicator_combobox(first_group)
def _load_indicators_into_cache(self, groups):
self.indicator_cache.clear()
for group in groups:
inds = self.logic.load_indicators_for_group(group.code)
self.indicator_cache[group.code] = inds
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)
# Извлекаем имена из списка кортежей [(name1,), (name2,)]
ind_names = [ind[0] for ind in indicators]
self.cmb_indicator.configure(values=ind_names)
if ind_names:
self.cmb_indicator.set(ind_names[0])
def _paste_from_clipboard(self):
try:
txt = self.clipboard_get()
self.txt_event.insert(tk.INSERT, txt)
except Exception:
pass
def _collect_current_selection(self) -> dict:
proj_name = self.window_ref.project_selector.get()
proj_objs = self.logic.load_project_list()
proj_code = next((p[2] for p in proj_objs if p[1] == proj_name), "")
group_name = self.cmb_group.get()
group_code = self.group_names_to_codes.get(group_name, "")
indicator_name = self.cmb_indicator.get()
indicator_code = ""
if group_code in self.indicator_cache:
for ind in self.indicator_cache[group_code]:
if ind.name == indicator_name:
indicator_code = ind.code
break
return {
"project_name": proj_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()
if not all([data['project_code'], data['group_code'], data['indicator_code']]):
messagebox.showwarning("Ошибка", "Заполнены не все обязательные поля.")
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)
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) -> List:
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 (Destroy + Create)
parent_widget = self.project_selector.master
self.project_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:
self.project_selector.set(fresh_names[-1])
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()
cursor.execute('''
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
code TEXT NOT NULL UNIQUE,
description TEXT DEFAULT '',
date_opened TEXT DEFAULT '',
responsible TEXT DEFAULT ''
)
''')
conn.commit()
def add_project(self, name: str, code: str, description: str, date_opened: str, responsible: str) -> bool:
"""Добавляет запись о проекте в общий список."""
try:
with self.connect() as conn:
conn.execute(
"INSERT INTO projects (name, code, description, date_opened, responsible) VALUES (?, ?, ?, ?, ?)",
(name, code, description, date_opened, responsible)
)
return True
except sqlite3.IntegrityError:
return False
except Exception:
return False
def list_projects(self) -> List[Tuple]:
"""Возвращает список кортежей (id, name, code) для комбобоксов."""
with self.connect() as conn:
cursor = conn.execute("SELECT id, name, code 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()
project_names_only = [p[1] for p in fresh_projects]
print(f"[DEBUG MAIN MENU] Проекты при открытии модуля: {project_names_only}")
# 1. Обновляем верхний ComboBox над аккордеоном
if hasattr(self._project_win, 'project_selector'):
self._project_win.project_selector.configure(values=project_names_only)
if project_names_only:
# Выбираем первый проект по умолчанию при открытии окна
self._project_win.project_selector.set(project_names_only[0])
else:
self._project_win.project_selector.set("Проектов нет")
# 2. Обновляем Treeview внутри секции создания проекта (если она открыта или для будущего)
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.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\manager\manager_metrics_panel.py
# ===============================================
import customtkinter as ctk
from tkinter import ttk, messagebox, filedialog
import tkinter as tk # Импортируем базовый tkinter для Text виджета
import pyperclip
from docx import Document
from modules.manager.manager_metrics_work import (
get_all_indicators_by_group, insert_indicator, delete_indicator_by_code,
IndicatorDTO, generate_unique_code, save_docx_template, update_indicator_name
)
from modules.manager.manager_group_work import list_groups as group_list
class ManagerMetricsPanel(ctk.CTkFrame):
def __init__(self, master):
super().__init__(master)
self.parent_main = master
self.group_names_to_codes = {}
self._current_editing_code = None
self._active_tooltip = None
# === СЕТКА ОКНА ===
self.grid_columnconfigure(0, weight=0)
self.grid_columnconfigure(1, weight=0)
self.grid_rowconfigure(0, weight=0)
self.grid_rowconfigure(1, weight=0)
self.grid_rowconfigure(2, weight=0)
self.grid_rowconfigure(3, weight=1)
self.grid_rowconfigure(4, weight=0)
# === СТРОКА 0: ВЫБОР ГРУППЫ ===
ctk.CTkLabel(self, text="Группа:").grid(row=0, column=0, sticky="w", padx=(10, 5), pady=(10, 0))
self.group_combobox = ctk.CTkComboBox(self, state="readonly", command=self._on_group_selected)
self.group_combobox.grid(row=0, column=0, columnspan=2, sticky="ew", padx=10, pady=(10, 0))
# === СТРОКА 1: КОНТЕЙНЕР ДЛЯ ПОЛЕЙ ===
input_frame = ctk.CTkFrame(self)
# Прижимаем к верху (Группа) и низу (Кнопки), убрав лишние отступы
input_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 5))
input_frame.grid_columnconfigure(0, weight=1)
# Вторая колонка больше не нужна для выравнивания поля, но может понадобиться для кнопки
input_frame.grid_columnconfigure(1, weight=0)
# Метка "Название"
ctk.CTkLabel(input_frame, text="Название показателя:", anchor="w").grid(row=0, column=0, sticky="w")
# ЕДИНСТВЕННОЕ ОСТАВШЕЕСЯ ПОЛЕ (многострочное)
self.name_entry = ctk.CTkTextbox(
input_frame, height=60, wrap="word", border_width=1
)
# Теперь это первая строка с контентом внутри фрейма (row=1)
self.name_entry.grid(row=1, column=0, sticky="ew", padx=(0, 5), pady=(2, 2))
# --- ЭТА ЧАСТЬ УДАЛЕНА ---
# self.code_entry = ctk.CTkEntry(input_frame, height=28)
# self.code_entry.grid(row=2, column=0, sticky="ew", padx=(0, 5), pady=(2, 5))
# -------------------------
# Привязки для Textbox остаются прежними
self.name_entry.bind("<Button-3>", self._paste_via_pyperclip)
self.name_entry.bind("<Control-v>", lambda e: (self._paste_via_pyperclip(e), "break"))
self.name_entry.bind("<Shift-Insert>", lambda e: (self._paste_via_pyperclip(e), "break"))
self.name_entry.bind("<Escape>", lambda e: self._reset_indicator_edit_mode())
# Кнопка вставки. Так как текстбокс теперь один, логичнее перенести кнопку на row=2,
# чтобы она визуально стояла под полем ввода, а не сбоку от него по центру высоты.
paste_btn = ctk.CTkButton(input_frame, text="Вставить", fg_color="#2B2B2B", hover_color="#3C3C3C", command=self._paste_via_pyperclip)
paste_btn.grid(row=2, column=0, sticky="e", padx=(0, 5), pady=(2, 5), ipadx=10)
# Если кнопка должна быть строго справа, используем две колонки:
# paste_btn.grid(row=1, column=1, sticky="nsew", padx=(5, 0), pady=5, ipadx=10)
# === СТРОКА 2: КНОПКИ УПРАВЛЕНИЯ ===
action_frame = ctk.CTkFrame(self)
action_frame.grid(row=2, column=0, columnspan=2, sticky="ew", padx=10, pady=(0, 0))
for i in range(4):
action_frame.grid_columnconfigure(i, weight=1, uniform="actions")
self.add_btn = ctk.CTkButton(action_frame, text="Добавить", fg_color="#3B82F6", command=self.add_indicator)
self.add_btn.grid(row=0, column=0, sticky="ew", padx=2, pady=2)
self.edit_btn = ctk.CTkButton(action_frame, text="Редактировать", fg_color="#10B981", command=self.edit_indicator_select)
self.edit_btn.grid(row=0, column=1, sticky="ew", padx=2, pady=2)
self.delete_btn = ctk.CTkButton(action_frame, text="Удалить", fg_color="#EF4444", command=self.delete_indicator)
self.delete_btn.grid(row=0, column=2, sticky="ew", padx=2, pady=2)
self.save_btn = ctk.CTkButton(action_frame, text="Сохранить", state="disabled", fg_color="#8B4513", hover_color="#A0522D", command=self.update_indicator_name)
self.cancel_edit_btn = ctk.CTkButton(action_frame, text="Отмена", fg_color="#6B7280", hover_color="#4B5563", command=self._reset_indicator_edit_mode)
# === СТРОКА 3: ТАБЛИЦА ДАННЫХ ===
tree_container = ctk.CTkFrame(self, fg_color="transparent")
tree_container.grid(row=3, column=0, columnspan=2, sticky="nsew", padx=10, pady=0)
tree_container.grid_columnconfigure(0, weight=1); tree_container.grid_rowconfigure(0, weight=1)
style = ttk.Style()
try:
theme_name = "clam" if "dark" in ctk.get_appearance_mode() else "default"
style.theme_use(theme_name)
style.configure("Custom.Treeview", background="#2b2b2b", fieldbackground="#2b2b2b", foreground="white", rowheight=30)
style.configure("Custom.Treeview.Heading", background="#34495e", foreground="white")
style.map("Custom.Treeview", background=[("selected", "#3c5a72")])
except Exception: pass
columns = ("number", "name", "code")
self.indicators_tree = ttk.Treeview(tree_container, columns=columns, show="headings", selectmode="browse", style="Custom.Treeview")
self.indicators_tree.grid(row=0, column=0, sticky="nsew")
scrollbar = ctk.CTkScrollbar(tree_container, orientation="vertical", command=self.indicators_tree.yview)
scrollbar.grid(row=0, column=1, sticky="ns", padx=(0, 2))
self.indicators_tree.configure(yscrollcommand=scrollbar.set)
self.indicators_tree.heading("number", text="№"); self.indicators_tree.column("number", width=50, anchor="center", stretch=False)
self.indicators_tree.heading("name", text="Название");
self.indicators_tree.column("name", width=0, minwidth=250, anchor="w", stretch=True)
self.indicators_tree.heading("code", text="Код"); self.indicators_tree.column("code", width=110, anchor="center", stretch=False)
self.indicators_tree.bind("<Button-1>", self._on_tree_click, add="+")
# === СТРОКА 4: ЭКСПОРТ ===
bottom_frame = ctk.CTkFrame(self)
bottom_frame.grid(row=4, column=0, columnspan=2, sticky="e", padx=10, pady=(0, 10))
ctk.CTkButton(bottom_frame, text="Экспорт в WORD", fg_color="#2B2B2B", hover_color="#3C3C3C", command=self.print_to_docx).pack(side="right", padx=5)
self.bind("<FocusOut>", self._on_window_focus_out)
self.update_group_combobox()
self.update_indicators_list()
self.after(100, lambda: self.name_entry.focus_set())
# --- МЕТОДЫ ПОДДЕРЖКИ ---
def refresh_groups_ui(self):
"""Публичный метод для принудительной перерисовки списка групп"""
self.update_group_combobox()
def _on_group_selected(self, event=None):
"""
Вызывается каждый раз, когда пользователь выбирает пункт в выпадающем списке.
Принудительно очищает таблицу и загружает актуальные данные.
"""
self.update_indicators_list()
def _paste_via_pyperclip(self, event=None):
try:
clipboard_text = pyperclip.paste()
if not clipboard_text:
return "break"
# Удаляем выделенный текст, если он есть
try:
self.name_entry.delete("sel.first", "sel.last")
except tk.TclError:
pass
# Вставляем содержимое буфера
self.name_entry.insert("insert", clipboard_text)
# Возвращаем фокус и ставим курсор в конец текста
self.name_entry.focus_set()
self.name_entry.mark_set("insert", "end")
except Exception as e:
print(f"[CLIPBOARD] Error: {e}")
return "break"
def update_group_combobox(self):
groups = group_list(); self.group_names_to_codes.clear()
for g in groups: self.group_names_to_codes[g.name] = getattr(g, 'code', '')
names = [g.name for g in groups]; self.group_combobox.configure(values=names)
if names: self.group_combobox.set(names[0]); self.update_indicators_list()
def update_indicators_list(self, event=None):
selected_group_code = self.group_names_to_codes.get(self.group_combobox.get())
indicators = get_all_indicators_by_group(selected_group_code) if selected_group_code else []
for item in self.indicators_tree.get_children(): self.indicators_tree.delete(item)
last_iid = None
for idx, ind in enumerate(indicators, start=1):
val_name = getattr(ind, 'name', '--')
val_code = getattr(ind, 'code', '--')
last_iid = val_code
self.indicators_tree.insert("", "end", iid=val_code, values=(f"{idx}.", val_name, val_code))
# Прокрутка к последнему добавленному элементу (Задача 4)
if last_iid:
self.indicators_tree.see(last_iid)
# --- ЛОГИКА КНОПОК И ИНТЕРАКТИВА ---
def add_indicator(self):
group_code = self.group_names_to_codes.get(self.group_combobox.get())
indicator_name = self.name_entry.get("1.0", "end").rstrip("\n").strip()
if not group_code or not indicator_name: messagebox.showwarning("Ошибка", "Выберите группу и введите название!"); return
new_code = generate_unique_code(group_code)
try:
insert_indicator(new_code, indicator_name, group_code)
messagebox.showinfo("Готово", "Индикатор добавлен."); self.update_indicators_list(); self.name_entry.delete("1.0", "end")
except Exception as e: messagebox.showerror("Ошибка", str(e))
def edit_indicator_select(self):
selected = self.indicators_tree.selection()
if not selected:
messagebox.showwarning("Ошибка", "Выберите показатель в таблице."); return
code = selected[0]
item = self.indicators_tree.item(code)
current_name = item["values"][1].replace('\n', ' ')
self.name_entry.delete("1.0", "end")
self.name_entry.insert("1.0", current_name)
# Используем правильное имя атрибута
self._current_editing_code = code
# Скрываем стандартные кнопки
self.edit_btn.grid_remove()
self.delete_btn.grid_remove()
# Показываем новые кнопки В ТЕ ЖЕ КОЛОНКИ (col 1 и col 2)
self.save_btn.grid(row=0, column=1, sticky="ew", padx=2, pady=2)
self.cancel_edit_btn.grid(row=0, column=2, sticky="ew", padx=2, pady=2)
self.save_btn.configure(state="normal")
def update_indicator_name(self):
# Исправлено с self._current_editing на self._current_editing_code
if not getattr(self, '_current_editing_code', None):
messagebox.showwarning("Ошибка", "Сначала выберите элемент через 'Редактировать'."); return
new_name = self.name_entry.get("1.0", "end").rstrip("\n").strip()
if not new_name:
messagebox.showwarning("Ошибка", "Название не может быть пустым."); return
success = update_indicator_name(self._current_editing_code, new_name)
if success:
messagebox.showinfo("Готово", "Название обновлено.")
self.update_indicators_list()
self._reset_indicator_edit_mode()
else:
messagebox.showerror("Ошибка БД", "Не удалось обновить данные.")
def _reset_indicator_edit_mode(self):
# Проверяем наличие правильного атрибута
if hasattr(self, '_current_editing_code'):
delattr(self, '_current_editing_code')
# Для CTkTextBox используем delete без координат
self.name_entry.delete("1.0", "end")
# --- ЭТО УДАЛЕНО ---
# self.code_entry.delete(0, "end")
# self.code_entry.configure(state="normal")
# -------------------
self.save_btn.configure(state="disabled")
self.save_btn.grid_remove()
self.cancel_edit_btn.grid_remove()
# Возвращаем кнопки на место. Обратите внимание: колонки grid_columnconfigure(action_frame) идут 0, 1, 2, 3.
# add_btn сидит в 0, значит edit_btn должен сидеть в 1, а delete_btn в 2.
self.edit_btn.grid(row=0, column=1, sticky="ew", padx=2, pady=2)
self.delete_btn.grid(row=0, column=2, sticky="ew", padx=2, pady=2)
self.indicators_tree.focus_set()
def delete_indicator(self):
selected = self.indicators_tree.selection()
if not selected: messagebox.showwarning("Ошибка", "Выберите показатель."); return
code = selected[0]
if messagebox.askyesno("Подтверждение", f"Удалить этот показатель?"):
try:
delete_indicator_by_code(code)
messagebox.showinfo("Готово", "Удалено."); self.update_indicators_list(); self.name_entry.delete("1.0", "end")
except Exception as e: messagebox.showerror("Ошибка", str(e))
def _on_tree_click(self, event):
region = self.indicators_tree.identify_region(event.x, event.y)
if region == "cell":
column_id = self.indicators_tree.identify_column(event.x)
if column_id == "#2":
item_id = self.indicators_tree.identify_row(event.y)
if item_id:
full_text = self.indicators_tree.item(item_id)['values'][1]
# Задача 3: Если тултип уже открыт — убиваем его перед созданием нового
if self._active_tooltip and self._active_tooltip.winfo_exists():
self._active_tooltip.destroy()
tooltip = ctk.CTkToplevel(self)
self._active_tooltip = tooltip
tooltip.attributes("-topmost", True)
tooltip.overrideredirect(True)
x = self.winfo_rootx() + event.x + 15
y = self.winfo_rooty() + event.y + 15
tooltip.geometry(f"+{x}+{y}")
label = ctk.CTkLabel(tooltip, text=full_text, wraplength=400, justify="left")
label.pack(padx=10, pady=10)
def on_leave(_):
if self._active_tooltip and self._active_tooltip.winfo_exists():
self._active_tooltip.destroy()
self._active_tooltip = None
tooltip.bind("<Leave>", on_leave)
def keep_open(_): return "break"
tooltip.bind("<Button-1>", keep_open)
def print_to_docx(self):
group_name = self.group_combobox.get()
if not group_name: messagebox.showwarning("Ошибка", "Выберите группу."); return
group_code = self.group_names_to_codes.get(group_name)
indicators = get_all_indicators_by_group(group_code)
if not indicators: messagebox.showwarning("Ошибка", "Нет данных для экспорта."); return
from datetime import date
filename = f"{group_name.replace(' ', '_')}_{date.today().strftime('%Y-%m-%d')}.docx"
path = filedialog.asksaveasfilename(initialfile=filename, defaultextension=".docx", filetypes=[("Word files", "*.docx"), ("All files", "*.*")])
if not path: return
success = save_docx_template(group_name, indicators, path)
if success: messagebox.showinfo("Успех", f"Сохранено:\n{path}")
else: messagebox.showerror("Ошибка записи", "Проверьте консоль или права доступа к папке.")
def _on_window_focus_out(self, event):
"""Закрывает всплывающую подсказку, если фокус ушел из основного окна."""
if self._active_tooltip and self._active_tooltip.winfo_exists():
self._active_tooltip.destroy()
self._active_tooltip = None
def on_close(self):
# Чистим за собой при закрытии панели
if self._active_tooltip and self._active_tooltip.winfo_exists():
self._active_tooltip.destroy()
self.destroy()
#====================================================
# ===============================================
# dukora/modules/manager/manager_metrics_work.py
# ===============================================
import os
import sqlite3
from typing import List, Any
from contextlib import contextmanager
from modules.data.database_module import get_db_connection
from typing import List, Any
class IndicatorDTO:
def __init__(self, id: int, code: str, name: str, group_code: str):
self.id = id
self.code = code
self.name = name
self.group_code = group_code
@contextmanager
def _get_cursor():
conn = get_db_connection()
try:
cursor = conn.cursor()
yield cursor
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def insert_indicator(code: str, name: str, group_code: str) -> IndicatorDTO:
print(f"[WORK LOGIC] INSERT | Код: {code}, Имя: {name}")
with _get_cursor() as cursor:
cursor.execute('''
CREATE TABLE IF NOT EXISTS indicators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
code TEXT NOT NULL UNIQUE,
group_code TEXT NOT NULL,
FOREIGN KEY (group_code) REFERENCES groups(code) ON DELETE CASCADE
)
''')
cursor.execute("INSERT INTO indicators (code, name, group_code) VALUES (?, ?, ?)",
(code, name, group_code))
new_id = cursor.lastrowid
return IndicatorDTO(id=new_id, code=code, name=name, group_code=group_code)
def delete_indicator_by_code(code: str):
print(f"[WORK LOGIC] DELETE | Код: {code}")
with _get_cursor() as cursor:
cursor.execute("DELETE FROM indicators WHERE code = ?", (code,))
def get_all_indicators_by_group(group_code: str) -> List[IndicatorDTO]:
print(f"[WORK LOGIC] SELECT | Группа: {group_code}")
with _get_cursor() as cursor:
cursor.execute("SELECT id, code, name, group_code FROM indicators WHERE group_code = ?", (group_code,))
rows = cursor.fetchall()
return [IndicatorDTO(id=row[0], code=row[1], name=row[2], group_code=row[3]) for row in rows]
# === ИСПРАВЛЕННАЯ ФУНКЦИЯ ОБНОВЛЕНИЯ ДЛЯ SQLITE ===
def update_indicator_name(code: str, new_name: str) -> bool:
"""
Обновляет название индикатора по его уникальному коду через SQLite.
"""
try:
with _get_cursor() as cursor:
# Сначала проверяем, существует ли такой индикатор
cursor.execute("SELECT name FROM indicators WHERE code = ?", (code,))
result = cursor.fetchone()
if not result:
print(f"[DB] Индикатор с кодом {code} не найден.")
return False
old_name = result[0]
# Выполняем обновление
cursor.execute("UPDATE indicators SET name = ? WHERE code = ?", (new_name.strip(), code))
# commit произойдет автоматически благодаря @contextmanager
print(f"[DB] Название обновлено: '{old_name}' -> '{new_name.strip()}'")
return True
except Exception as e:
# Rollback сработает автоматически благодаря декораторам контекста
print(f"[DB FATAL] Ошибка при обновлении индикатора: {repr(e)}")
return False
def generate_unique_code(group_code: str) -> str:
"""
Генерирует уникальный код показателя внутри группы по шаблону ГРУППА-N.
Например: Эко-1, Эко-2, ИТ-1.
"""
with _get_cursor() as cursor:
cursor.execute("""
SELECT MAX(CAST(substr(code, instr(code, '-') + 1) AS INTEGER))
FROM indicators WHERE code LIKE ?
""", (f"{group_code}-%",))
row = cursor.fetchone()[0]
if row is None:
next_num = 1
else:
next_num = row + 1
return f"{group_code}-{next_num}"
def save_docx_template(group_name: str, indicators: List[Any], path: str) -> bool:
if not indicators:
return False
try:
from docx import Document
from docx.shared import Cm, Pt
from docx.enum.section import WD_ORIENTATION
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
doc = Document()
# --- СТРАНИЦА ---
section = doc.sections[0]
section.orientation = WD_ORIENTATION.LANDSCAPE
section.page_width = Cm(29.7)
section.page_height = Cm(21.0)
section.left_margin = Cm(3.0)
section.right_margin = Cm(2.0)
section.top_margin = Cm(2.0)
section.bottom_margin = Cm(2.0)
# --- СТИЛЬ ---
style = doc.styles['Normal']
font = style.font
font.name = 'Times New Roman'
font.size = Pt(12)
# --- ЗАГОЛОВОК ---
p = doc.add_paragraph(f'Показатели группы: {group_name}')
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.runs[0].font.bold = True
p.runs[0].font.size = Pt(14)
doc.add_paragraph(' ')
# --- ТАБЛИЦА ---
table = doc.add_table(rows=len(indicators) + 1, cols=3)
table.style = 'Table Grid'
table.autofit = False
table.alignment = WD_TABLE_ALIGNMENT.CENTER
col_widths_cm = [2.0, 2.0, 20.5]
for i in range(3):
for cell in table.columns[i].cells:
cell.width = Cm(col_widths_cm[i])
# Шапка
hdr_cells = table.rows[0].cells
titles = ['№ п/п', 'Код', 'Наименование']
for i in range(3):
hdr_cells[i].text = titles[i]
para = hdr_cells[i].paragraphs[0]
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = para.runs[0]
run.bold = True
run.font.size = Pt(14)
run.font.name = 'Times New Roman'
# Данные
for idx, ind in enumerate(indicators, start=1):
row_cells = table.rows[idx].cells
for i, cell in enumerate(row_cells):
cell.width = Cm(col_widths_cm[i])
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.TOP
row_cells[0].text = str(idx)
row_cells[1].text = ind.code
row_cells[2].text = ind.name.replace('\\n', '\n')
for cell in row_cells:
for para in cell.paragraphs:
for run in para.runs:
run.font.name = 'Times New Roman'
run.font.size = Pt(12)
doc.save(path)
return True
except Exception as e:
print(f"[WORK LOGIC FATAL] Ошибка: {repr(e)}")
return False
# ===============================================