/
AxeOn
/
ProjectAi
Обзор
Документация
Войти
/
AxeOn
/
ProjectAi
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
base_module.py
673 строки
24 KB
Дмитрий
chore: base state before refactoring (office_planner_v5)
25 апр 2026, 15:01
25 апр 2026, 15:01
bc896b2
Код
Авторство
О чём код?
# base_module.py """ Базовый класс для всех модулей приложения Устраняет дублирование кода между vacation_module, employee_module, load_module, calendar_module и salary_module. Предоставляет: - Стандартный UI с Treeview - CRUD операции - Экспорт в Excel - Фильтрацию и поиск - Сортировку """ import ttkbootstrap as ttk from tkinter import messagebox, filedialog from ttkbootstrap.tooltip import ToolTip from abc import ABC, abstractmethod from typing import List, Tuple, Dict, Optional, Any import pandas as pd import logging logger = logging.getLogger(__name__) def setup_combobox_search(cb, full_values): """ Добавляет поиск по набору символов с клавиатуры к ttk.Combobox. Для state="readonly": перехватывает нажатия клавиш, накапливает буфер, фильтрует список и автоматически открывает выпадающий список. Для state="normal": фильтрует dropdown при вводе текста (через KeyRelease). Управление: • Любой символ — добавить в буфер поиска, показать совпадения • Backspace — удалить последний символ буфера • Escape — сбросить буфер, очистить поле • Enter / Up / Down — стандартное поведение (навигация / подтверждение) • 3 с без ввода — буфер сбрасывается, список восстанавливается Пример: names = ["Иванов И.И.", "Петров П.П.", ...] cb = ttk.Combobox(frame, values=names, state="readonly") setup_combobox_search(cb, names) """ _all = list(full_values) _buf = [''] _timer = [None] def _cancel_timer(): if _timer[0]: cb.after_cancel(_timer[0]) _timer[0] = None def _restore(): _buf[0] = '' cb['values'] = _all def _filter_and_open(): q = _buf[0].lower() filt = [v for v in _all if q in str(v).lower()] if q else _all cb['values'] = filt if filt else _all if filt and q: cb.set(filt[0]) try: cb.tk.call('ttk::combobox::Post', cb) except Exception: pass def _on_selected(event=None): _cancel_timer() _restore() def _on_key_readonly(event): sym, ch = event.keysym, event.char if sym in ('Up', 'Down', 'Prior', 'Next', 'Tab', 'F4'): return # стандартная навигация if sym == 'Return': _cancel_timer() _restore() return if sym == 'Escape': _cancel_timer() _restore() cb.set('') return 'break' if sym == 'BackSpace': _buf[0] = _buf[0][:-1] elif ch and ch.isprintable(): _buf[0] += ch else: return _cancel_timer() _filter_and_open() _timer[0] = cb.after(3000, _restore) return 'break' def _on_keyrelease_normal(event): sym = event.keysym if sym in ('Return', 'Up', 'Down', 'Prior', 'Next', 'Tab', 'Escape', 'F4'): if sym == 'Escape': cb.set('') cb['values'] = _all return typed = cb.get().lower() filt = [v for v in _all if typed in str(v).lower()] if typed else _all cb['values'] = filt if filt else _all if typed: try: cb.tk.call('ttk::combobox::Post', cb) except Exception: pass def _on_focusout_normal(event=None): cb['values'] = _all state = str(cb.cget('state')) if state == 'readonly': # For readonly comboboxes the internal .entry subwidget holds keyboard focus, # not the outer cb widget. The default bindtags of .entry are: # (.entry, TEntry, <Toplevel>, all) # Because <Toplevel> is in the chain, toplevel.bind('<KeyPress>', ...) fires # for every key press while .entry has focus. We use this to intercept input. def _setup_tl_binding(): try: toplevel = cb.winfo_toplevel() cb_path = str(cb) def _tl_key(event): fw = cb.focus_get() if fw is None: return fw_str = str(fw) if fw_str == cb_path or fw_str.startswith(cb_path + '.'): return _on_key_readonly(event) toplevel.bind('<KeyPress>', _tl_key, add=True) except Exception: cb.bind('<KeyPress>', _on_key_readonly) cb.after(0, _setup_tl_binding) cb.bind('<<ComboboxSelected>>', _on_selected, add=True) else: cb.bind('<KeyRelease>', _on_keyrelease_normal, add=True) cb.bind('<FocusOut>', _on_focusout_normal, add=True) cb.bind('<<ComboboxSelected>>', _on_selected, add=True) class BaseModule(ttk.Frame, ABC): """ Абстрактный базовый класс для модулей приложения Наследники должны реализовать: - get_columns() - определение колонок таблицы - get_data() - получение данных из БД - create_input_form() - создание формы ввода - validate_input() - валидация данных - add_item() - добавление записи - update_item() - обновление записи - delete_item() - удаление записи """ def __init__(self, master, db_manager, module_name: str = "Модуль"): """ Args: master: Родительский виджет db_manager: Экземпляр DBManager module_name: Название модуля для заголовков """ super().__init__(master) self.db = db_manager self.module_name = module_name self.editing_id: Optional[int] = None self.pack(fill="both", expand=True) # Создание UI self._setup_ui() # Загрузка данных self.refresh_data() # === ABSTRACT METHODS (должны быть реализованы в наследниках) === @abstractmethod def get_columns(self) -> List[Tuple[str, str, int]]: """ Определить колонки для Treeview Returns: List of (column_id, column_name, column_width) Example: return [ ("id", "ID", 50), ("name", "Имя", 200), ("department", "Отдел", 150) ] """ pass @abstractmethod def get_data(self, filters: Optional[Dict] = None) -> List[Dict]: """ Получить данные из БД Args: filters: Словарь фильтров (опционально) Returns: List of dictionaries with data """ pass @abstractmethod def create_input_form(self, parent_frame: ttk.Frame): """ Создать форму ввода данных Args: parent_frame: Фрейм для размещения виджетов ввода Note: Сохраните ссылки на виджеты ввода как self.entry_* для последующего доступа в get_input_data() и set_input_data() """ pass @abstractmethod def get_input_data(self) -> Optional[Dict]: """ Получить данные из формы ввода Returns: Dictionary with input data or None if validation failed """ pass @abstractmethod def set_input_data(self, data: Dict): """ Заполнить форму данными (для редактирования) Args: data: Dictionary with data to fill form """ pass @abstractmethod def add_item(self, data: Dict) -> bool: """ Добавить запись в БД Args: data: Dictionary with data Returns: True if successful """ pass @abstractmethod def update_item(self, item_id: int, data: Dict) -> bool: """ Обновить запись в БД Args: item_id: ID записи data: Dictionary with updated data Returns: True if successful """ pass @abstractmethod def delete_item(self, item_id: int) -> bool: """ Удалить запись из БД Args: item_id: ID записи Returns: True if successful """ pass # === UI SETUP === def _setup_ui(self): """Создание стандартного UI""" # Контрольная панель self.control_frame = ttk.Frame(self, padding=10) self.control_frame.pack(fill="x", side="top") # Панель ввода (форма) self.input_frame = ttk.Labelframe( self.control_frame, text=f"➕ Добавить/Изменить", padding=10 ) self.input_frame.pack(side="left", fill="both", expand=True, padx=5) # Создание формы (реализуется в наследниках) self.create_input_form(self.input_frame) # Панель кнопок self.button_frame = ttk.Frame(self.control_frame, padding=5) self.button_frame.pack(side="right", fill="y") self._create_buttons() # Таблица с данными self._create_table() def _create_buttons(self): """Создание кнопок управления""" # Добавить btn_add = ttk.Button( self.button_frame, text="➕ Добавить", command=self.on_add, bootstyle="success", width=15 ) btn_add.pack(pady=3) ToolTip(btn_add, text="Добавить новую запись", bootstyle="inverse-success") # Сохранить изменения self.btn_save = ttk.Button( self.button_frame, text="💾 Сохранить", command=self.on_save, bootstyle="warning", state="disabled", width=15 ) self.btn_save.pack(pady=3) ToolTip(self.btn_save, text="Сохранить изменения", bootstyle="inverse-warning") # Отмена редактирования btn_cancel = ttk.Button( self.button_frame, text="↶ Отмена", command=self.on_cancel, bootstyle="secondary", width=15 ) btn_cancel.pack(pady=3) ToolTip(btn_cancel, text="Отменить редактирование", bootstyle="inverse-secondary") # Удалить btn_delete = ttk.Button( self.button_frame, text="🗑 Удалить", command=self.on_delete, bootstyle="danger", width=15 ) btn_delete.pack(pady=3) ToolTip(btn_delete, text="Удалить выбранную запись", bootstyle="inverse-danger") # Разделитель ttk.Separator(self.button_frame, orient="horizontal").pack(fill="x", pady=10) # Обновить данные btn_refresh = ttk.Button( self.button_frame, text="🔄 Обновить", command=self.refresh_data, bootstyle="info", width=15 ) btn_refresh.pack(pady=3) ToolTip(btn_refresh, text="Обновить данные", bootstyle="inverse-info") # Экспорт btn_export = ttk.Button( self.button_frame, text="📊 Экспорт", command=self.export_to_excel, bootstyle="info-outline", width=15 ) btn_export.pack(pady=3) ToolTip(btn_export, text="Экспорт в Excel", bootstyle="inverse-info") def _create_table(self): """Создание таблицы для отображения данных""" # Фрейм для таблицы table_frame = ttk.Frame(self) table_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10)) # Получаем определение колонок columns = self.get_columns() col_ids = [col[0] for col in columns] # Создание Treeview self.tree = ttk.Treeview( table_frame, columns=col_ids, show="headings", selectmode="browse", bootstyle="primary" ) # Настройка колонок for col_id, col_name, col_width in columns: self.tree.heading(col_id, text=col_name, command=lambda c=col_id: self.on_sort(c)) self.tree.column(col_id, width=col_width, anchor="w") # Scrollbar scrollbar = ttk.Scrollbar( table_frame, orient="vertical", command=self.tree.yview ) self.tree.configure(yscrollcommand=scrollbar.set) # Размещение self.tree.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") # Привязка событий self.tree.bind("<<TreeviewSelect>>", self.on_select) self.tree.bind("<Double-Button-1>", lambda e: self.on_edit()) # === EVENT HANDLERS === def on_add(self): """Обработчик добавления записи""" data = self.get_input_data() if data is None: return # Валидация не прошла try: if self.add_item(data): self.refresh_data() self.clear_input_form() messagebox.showinfo("Успех", "Запись добавлена") else: messagebox.showerror("Ошибка", "Не удалось добавить запись") except Exception as e: logger.error(f"Error adding item: {e}", exc_info=True) messagebox.showerror("Ошибка", f"Ошибка при добавлении: {e}") def on_save(self): """Обработчик сохранения изменений""" if self.editing_id is None: messagebox.showwarning("Предупреждение", "Нет записи для сохранения") return data = self.get_input_data() if data is None: return # Валидация не прошла try: if self.update_item(self.editing_id, data): self.refresh_data() self.clear_input_form() self.editing_id = None self.btn_save.config(state="disabled") messagebox.showinfo("Успех", "Изменения сохранены") else: messagebox.showerror("Ошибка", "Не удалось сохранить изменения") except Exception as e: logger.error(f"Error saving item: {e}", exc_info=True) messagebox.showerror("Ошибка", f"Ошибка при сохранении: {e}") def on_cancel(self): """Обработчик отмены редактирования""" self.clear_input_form() self.editing_id = None self.btn_save.config(state="disabled") def on_edit(self): """Обработчик начала редактирования""" selection = self.tree.selection() if not selection: messagebox.showwarning("Предупреждение", "Выберите запись для редактирования") return # Получаем ID записи (первая колонка) item_values = self.tree.item(selection[0])['values'] self.editing_id = int(item_values[0]) # Получаем данные из БД data_list = self.get_data() item_data = next((item for item in data_list if item.get('id') == self.editing_id), None) if item_data: self.set_input_data(item_data) self.btn_save.config(state="normal") else: messagebox.showerror("Ошибка", "Запись не найдена") def on_delete(self): """Обработчик удаления записи""" selection = self.tree.selection() if not selection: messagebox.showwarning("Предупреждение", "Выберите запись для удаления") return # Подтверждение if not messagebox.askyesno("Подтверждение", "Вы уверены, что хотите удалить эту запись?"): return # Получаем ID записи item_values = self.tree.item(selection[0])['values'] item_id = int(item_values[0]) try: if self.delete_item(item_id): self.refresh_data() self.clear_input_form() messagebox.showinfo("Успех", "Запись удалена") else: messagebox.showerror("Ошибка", "Не удалось удалить запись") except Exception as e: logger.error(f"Error deleting item: {e}", exc_info=True) messagebox.showerror("Ошибка", f"Ошибка при удалении: {e}") def on_select(self, event): """Обработчик выбора записи в таблице""" # Можно переопределить в наследниках для дополнительной логики pass def on_sort(self, col_id: str): """Обработчик сортировки по колонке""" # Простая сортировка (можно улучшить) data = [(self.tree.set(item, col_id), item) for item in self.tree.get_children('')] data.sort(reverse=False) for index, (val, item) in enumerate(data): self.tree.move(item, '', index) # === DATA MANAGEMENT === def refresh_data(self, filters: Optional[Dict] = None): """ Обновить данные в таблице Args: filters: Словарь фильтров (опционально) """ # Очистка таблицы for item in self.tree.get_children(): self.tree.delete(item) try: # Загрузка данных data = self.get_data(filters) # Получаем ID колонок col_ids = [col[0] for col in self.get_columns()] # Заполнение таблицы for row in data: values = [row.get(col_id, "") for col_id in col_ids] self.tree.insert("", "end", values=values) logger.info(f"Loaded {len(data)} rows in {self.module_name}") except Exception as e: logger.error(f"Error refreshing data: {e}", exc_info=True) messagebox.showerror("Ошибка", f"Ошибка загрузки данных: {e}") def clear_input_form(self): """Очистить форму ввода (должно быть реализовано в наследниках)""" # Базовая реализация - можно переопределить pass def export_to_excel(self): """Экспорт данных в Excel""" try: data = self.get_data() if not data: messagebox.showinfo("Информация", "Нет данных для экспорта") return # Диалог сохранения файла filename = filedialog.asksaveasfilename( title=f"Экспорт {self.module_name}", defaultextension=".xlsx", filetypes=[("Excel files", "*.xlsx"), ("All files", "*.*")] ) if not filename: return # Пользователь отменил # Создание DataFrame и экспорт df = pd.DataFrame(data) df.to_excel(filename, index=False, sheet_name=self.module_name) messagebox.showinfo("Успех", f"Данные экспортированы в:\n{filename}") logger.info(f"Data exported to {filename}") except Exception as e: logger.error(f"Error exporting data: {e}", exc_info=True) messagebox.showerror("Ошибка", f"Ошибка экспорта: {e}") # === EXAMPLE IMPLEMENTATION === class ExampleModule(BaseModule): """Пример использования BaseModule""" def __init__(self, master, db_manager): super().__init__(master, db_manager, module_name="Пример") def get_columns(self): return [ ("id", "ID", 50), ("name", "Название", 200), ("description", "Описание", 300), ("created_at", "Создано", 150) ] def get_data(self, filters=None): # Пример: получить данные из БД return self.db.get_some_data(filters) def create_input_form(self, parent_frame): # Название ttk.Label(parent_frame, text="Название:").grid(row=0, column=0, sticky="w", pady=5) self.entry_name = ttk.Entry(parent_frame, width=30) self.entry_name.grid(row=0, column=1, pady=5, padx=5) # Описание ttk.Label(parent_frame, text="Описание:").grid(row=1, column=0, sticky="w", pady=5) self.entry_desc = ttk.Entry(parent_frame, width=30) self.entry_desc.grid(row=1, column=1, pady=5, padx=5) def get_input_data(self): name = self.entry_name.get().strip() desc = self.entry_desc.get().strip() if not name: messagebox.showwarning("Валидация", "Название не может быть пустым") return None return { 'name': name, 'description': desc } def set_input_data(self, data): self.entry_name.delete(0, 'end') self.entry_name.insert(0, data.get('name', '')) self.entry_desc.delete(0, 'end') self.entry_desc.insert(0, data.get('description', '')) def clear_input_form(self): self.entry_name.delete(0, 'end') self.entry_desc.delete(0, 'end') def add_item(self, data): return self.db.add_item(data) def update_item(self, item_id, data): return self.db.update_item(item_id, data) def delete_item(self, item_id): return self.db.delete_item(item_id)