/
AxeOn
/
ProjectAi
Обзор
Документация
Войти
/
AxeOn
/
ProjectAi
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
controller.py
966 строк
45 KB
Дмитрий
feat(architecture): introduce core dataclasses in models.py and type DB returns
25 апр 2026, 16:23
25 апр 2026, 16:23
cdbece0
Код
Авторство
О чём код?
# controller.py from ui import TaskView, CommentDialog from services import VoiceService from event_bus import EventBus, EventType from constants import TaskStatus, Priority, DateFilter from tg_settings_dialog import TGSettingsDialog import threading import time import logging import re import queue from datetime import datetime, timedelta, date from tkinter import messagebox, filedialog, simpledialog import pandas as pd # Попытка импорта fuzzy search для голосового ввода try: from rapidfuzz import process, fuzz RAPIDFUZZ_AVAILABLE = True except ImportError: RAPIDFUZZ_AVAILABLE = False class TaskController: def __init__(self, root, db_manager, view_class, telegram_service): self.root = root self.db = db_manager self.tg = telegram_service self.undo_stack = [] self.editing_item_id = None # Поля для сортировки self.sort_col = None self.sort_reverse = False # 1. Инициализация голосового сервиса self.voice_service = VoiceService(self.on_voice_text, self.on_voice_status) # 2. Инициализация UI self.view = view_class(root, self.get_callbacks()) # 3. Загрузка данных self.load_tasks() self.load_assignees_to_ui() # 4. Проверка дней рождений и отпусков (в отдельных потоках) threading.Thread(target=self.check_birthdays, daemon=True).start() threading.Thread(target=self.check_vacations, daemon=True).start() # 5. Подписка на события EventBus.subscribe(EventType.EMPLOYEE_UPDATED, self.reload_assignees) # 6. Автоматическая синхронизация TG-опросов (при старте и каждый час) threading.Thread(target=self._auto_sync_loop, daemon=True, name="TGAutoSync").start() # 7. Thread-safe очередь для перезагрузки задач из TG-потоков self._reload_queue = queue.Queue() self.root.after(500, self._drain_reload_queue) def get_callbacks(self): """Связываем кнопки UI с методами контроллера""" return { 'add_root': self.add_root, 'add_sub': self.add_sub, 'edit': self.edit_load, 'save_edit': self.edit_save, 'delete': self.delete, 'comment': self.open_comment_dialog, 'set_status': self.set_status, 'voice': self.voice_service.toggle_record, 'open_emp': self.open_emp, 'tg_send': self.tg_send, 'tg_send_all': self.tg_send_all, 'tg_sync': self.tg_sync, 'export': self.export, 'apply_filter': self.load_tasks, 'reset_filter': self.reset_filter, 'move_task': self.move_task, 'undo': self.undo, 'shift_date': self.shift_date, 'sort': self.sort_tasks, 'tg_stats': self.show_tg_stats } # === ЛОГИКА ОТПУСКОВ (НОВОЕ) === def check_vacations(self, force=False): try: today = datetime.now().date() today_str = today.strftime("%Y-%m-%d") last_check = self.db.get_setting("last_vacation_check") if not force and last_check == today_str: return # Получаем все отпуска на текущий и следующий год (оптимизация) vacations = self.db.get_vacations(year=today.year) # Если сегодня декабрь, можно захватить и следующий, но для простоты берем все или фильтруем # В данном случае берем результат get_vacations (который может быть за год) # Для надежности лучше перебрать все будущие, но используем то что дает метод sent_count = 0 # Загружаем всех сотрудников один раз: два индекса — по id и по имени. # Имена руководителей хранятся в поле managers как строка через запятую, # поэтому нужен именно name-индекс, чтобы не делать N+1 запросов в цикле. _all_emps = self.db.get_employees() all_employees = {e['id']: e for e in _all_emps} all_employees_by_name = {e['name']: e for e in _all_emps} for vac in vacations: try: s_date = datetime.strptime(vac['start_date'], "%Y-%m-%d").date() e_date = datetime.strptime(vac['end_date'], "%Y-%m-%d").date() if s_date <= today: continue # Пропускаем прошедшие/текущие # Считаем даты уведомлений notify_5 = self.get_working_days_before(s_date, 5) notify_1 = self.get_working_days_before(s_date, 1) emp_data = all_employees.get(vac['employee_id']) if not emp_data: continue managers_str = emp_data['managers'] managers_ids = [] if managers_str: for m_name in managers_str.split(','): mgr = all_employees_by_name.get(m_name.strip()) if mgr and mgr['tg_id']: managers_ids.append(mgr['tg_id']) # УВЕДОМЛЕНИЕ ЗА 5 ДНЕЙ if today == notify_5: days_len = (e_date - s_date).days + 1 msg = (f"🌴 <b>Планируемый отпуск</b>\n\n" f"Напоминаем, что <b>{emp_data['name']}</b> с {s_date.strftime('%d.%m.%Y')} " f"уходит в отпуск на <b>{days_len} дн.</b>\n" f"Необходимо уточнить данную информацию и напомнить написать заявление.") for chat_id in managers_ids: if self.tg.send_message(chat_id, msg): sent_count += 1 time.sleep(0.5) # УВЕДОМЛЕНИЕ ЗА 1 ДЕНЬ elif today == notify_1: msg = (f"🌴 <b>Напоминание об отпуске</b>\n\n" f"<b>{emp_data['name']}</b> с {s_date.strftime('%d.%m.%Y')} " f"по {e_date.strftime('%d.%m.%Y')} будет находиться в отпуске.") # 1. Руководителям for chat_id in managers_ids: if self.tg.send_message(chat_id, msg): sent_count += 1 time.sleep(0.5) # 2. В рабочую группу if emp_data['working_group_id']: grp_tg_id = self.db.get_working_group_tg_id(emp_data['working_group_id']) if grp_tg_id: if self.tg.send_message(grp_tg_id, msg): sent_count += 1 time.sleep(0.5) except Exception as e: logging.error(f"Error processing vacation {vac}: {e}") if sent_count > 0 or not force: self.db.set_setting("last_vacation_check", today_str) except Exception as e: logging.error(f"Vacation check error: {e}") def get_working_days_before(self, target_date, days_count): """Возвращает дату за N рабочих дней до целевой""" current = target_date - timedelta(days=1) count = 0 # Ограничитель на случай бесконечного цикла (например, если нет рабочих дней в календаре) limit = 30 while count < days_count and limit > 0: if self.is_working_day(current): count += 1 if count < days_count: current -= timedelta(days=1) limit -= 1 return current # === ЛОГИКА ДНЕЙ РОЖДЕНИЙ === def check_birthdays(self, force=False): """Проверяет дни рождения и шлет уведомления за 1 рабочий день до события""" try: last_check = self.db.get_setting("last_birthday_check") today = datetime.now().date() today_str = today.strftime("%Y-%m-%d") if not force and last_check == today_str: return employees = self.db.get_employees() sent_count = 0 for emp in employees: bdate_str = emp['birth_date'] if not bdate_str: continue try: if "." in bdate_str: bd_src = datetime.strptime(bdate_str, "%d.%m.%Y").date() else: bd_src = datetime.strptime(bdate_str, "%Y-%m-%d").date() except ValueError: continue try: b_this_year = date(today.year, bd_src.month, bd_src.day) except ValueError: b_this_year = date(today.year, 3, 1) if b_this_year < today: b_this_year = date(today.year + 1, bd_src.month, bd_src.day) # Используем новый метод расчета (за 1 день) notify_date = self.get_working_days_before(b_this_year, 1) if notify_date == today: managers_str = emp['managers'] if not managers_str: continue mgr_names = [m.strip() for m in managers_str.split(",") if m.strip()] fio_genitive = self._decline_fio(emp['name']) date_display = b_this_year.strftime("%d.%m.%Y") for mgr_name in mgr_names: mgr_data = self.db.get_employee_by_name(mgr_name) if mgr_data and mgr_data.tg_id: msg = (f"🎂🎈 <b>Напоминание о Дне Рождения!</b>\n\n" f"Напоминаем, что <b>{date_display}</b> у " f"<b>{fio_genitive}</b> день рождения! 🎁\n" f"Отдел: {emp['department']}") if self.tg.send_message(mgr_data.tg_id, msg): sent_count += 1 time.sleep(0.5) if sent_count > 0 or not force: self.db.set_setting("last_birthday_check", today_str) except Exception as e: logging.error(f"Birthday check error: {e}") def _decline_fio(self, name): parts = name.split() res = [] for word in parts: w_lower = word.lower() if len(word) < 2: res.append(word) continue if w_lower[-1] in "бвгджзклмнпрстфхцчшщ" or w_lower.endswith("й"): if w_lower.endswith("й"): res.append(word[:-1] + "я") else: res.append(word + "а") elif w_lower.endswith("а"): res.append(word[:-1] + "ы") elif w_lower.endswith("я"): res.append(word[:-1] + "и") elif w_lower.endswith("ов") or w_lower.endswith("ев") or w_lower.endswith("ин"): res.append(word + "а") else: res.append(word) return " ".join(res) # === СТАТИСТИКА TG === def show_tg_stats(self): try: sent, resp, msgs = self.db.get_tg_stats_today() from datetime import datetime today = datetime.now().strftime("%d.%m.%Y") msg = (f"Статистика Telegram за {today}:\n\n" f"Опросов отправлено: {sent}\n" f"Ответов получено: {resp}\n" f"Уведомлений отправлено:{msgs}") messagebox.showinfo("Статистика TG", msg) except Exception as e: logging.error(f"TG stats error: {e}") messagebox.showerror("Ошибка", f"Не удалось получить статистику: {e}") # === ВСПОМОГАТЕЛЬНЫЙ МЕТОД ДЛЯ TELEGRAM === def _send_tg_safe(self, new_id, title, priority, date_str, assignee): if not self.tg.token: return try: self.tg.send_poll_one(new_id, title, priority, date_str, assignee, show_error=True) except Exception as e: logging.error(f"Thread Error (TG Send): {e}") # === ЛОГИКА ГОЛОСА === def on_voice_text(self, text): if RAPIDFUZZ_AVAILABLE: try: title, priority, date_str, assignee = self.parse_voice_fuzzy(text) except Exception as e: logging.error(f"Voice parse error: {e}") title, priority, date_str, assignee = text, None, None, None else: title, priority, date_str, assignee = text, None, None, None self.root.after(0, lambda: self._update_ui_with_voice(text, title, priority, date_str, assignee)) def _update_ui_with_voice(self, original_text, title, priority, date_str, assignee): self.view.set_status(f"Распознано: {original_text}") self.view.entry_task.delete("1.0", "end") self.view.entry_task.insert("1.0", title) if priority: self.view.combo_priority.set(priority) if date_str: try: self.view.entry_date.entry.delete(0, "end") self.view.entry_date.entry.insert(0, date_str) except Exception as e: logging.error(f"Voice date UI update error: {e}") if assignee: self.view.combo_assignee.set(assignee) def on_voice_status(self, text, is_error=False): self.root.after(0, lambda: self.view.set_status(text, is_error=is_error)) def parse_voice_fuzzy(self, text): try: lower_text = text.lower() lower_text = re.sub(r'[^\w\s]', '', lower_text) words = lower_text.split() today = datetime.now().date() assignee = None employees = self.db.get_employees() emp_map = {} for e in employees: full_name = str(e['name']) parts = full_name.lower().split() for p in parts: if len(p) > 2: emp_map[p] = full_name words_to_remove = [] for i, word in enumerate(words): if not emp_map: break match = process.extractOne(word, emp_map.keys(), scorer=fuzz.ratio, score_cutoff=85) if match: matched_part = match[0] assignee = emp_map[matched_part] words_to_remove.append(word) break priority = None prio_map = { "высокий": Priority.HIGH, "срочно": Priority.HIGH, "важно": Priority.HIGH, "горим": Priority.HIGH, "низкий": Priority.LOW, "не срочно": Priority.LOW, "потом": Priority.LOW, "средний": Priority.MEDIUM, "обычный": Priority.MEDIUM, "нормально": Priority.MEDIUM } for word in words: if word in words_to_remove: continue match = process.extractOne(word, prio_map.keys(), scorer=fuzz.ratio, score_cutoff=85) if match and not priority: priority = prio_map[match[0]]; words_to_remove.append(word) garbage_keywords = ["приоритет", "срок", "исполнитель", "задача", "поставить", "назначить", "сделать", "для", "на", "статус"] clean_words = [] for word in words: if word in words_to_remove: continue if process.extractOne(word, garbage_keywords, scorer=fuzz.ratio, score_cutoff=85): continue clean_words.append(word) current_text_clean = " ".join(clean_words) target_date = None # 1. Фиксированные слова for _phrase, _delta in [("послезавтра", 2), ("завтра", 1)]: if _phrase in current_text_clean: target_date = today + timedelta(days=_delta) current_text_clean = current_text_clean.replace(_phrase, "") break if not target_date and "сегодня" in current_text_clean: target_date = today current_text_clean = current_text_clean.replace("сегодня", "") # 2. "через N дней / недель / месяц" if not target_date: _nw = { 'один': 1, 'одну': 1, 'два': 2, 'две': 2, 'три': 3, 'четыре': 4, 'пять': 5, 'шесть': 6, 'семь': 7, 'восемь': 8, 'девять': 9, 'десять': 10, } _np = '|'.join(_nw.keys()) _m = re.search( rf'через\s+(\d+|{_np})\s+(день|дня|дней|неделю|недели|недель)', current_text_clean) if _m: _n = int(_m.group(1)) if _m.group(1).isdigit() else _nw.get(_m.group(1), 1) if _m.group(2) in ('неделю', 'недели', 'недель'): _n *= 7 target_date = today + timedelta(days=_n) current_text_clean = (current_text_clean[:_m.start()] + current_text_clean[_m.end():]) elif re.search(r'через\s+месяц', current_text_clean): target_date = today + timedelta(days=30) current_text_clean = re.sub(r'через\s+месяц', '', current_text_clean) # 3. День недели → ближайший такой день (если сегодня — берём следующую неделю) if not target_date: _wdays = { 'понедельник': 0, 'вторник': 1, 'среда': 2, 'среду': 2, 'четверг': 3, 'пятница': 4, 'пятницу': 4, 'пятницы': 4, 'суббота': 5, 'субботу': 5, 'субботы': 5, 'воскресенье': 6, 'воскресенья': 6, } for _wd, _wn in _wdays.items(): if re.search(rf'\b{_wd}\b', current_text_clean): _ahead = (_wn - today.weekday()) % 7 or 7 target_date = today + timedelta(days=_ahead) current_text_clean = re.sub(rf'\b{_wd}\b', '', current_text_clean) break # 4. "24 февраля" — конкретное число и месяц if not target_date: _months = { 'января': 1, 'февраля': 2, 'марта': 3, 'апреля': 4, 'мая': 5, 'июня': 6, 'июля': 7, 'августа': 8, 'сентября': 9, 'октября': 10, 'ноября': 11, 'декабря': 12, } _mp = '|'.join(_months.keys()) _m = re.search(rf'(\d{{1,2}})\s+({_mp})', current_text_clean) if _m: try: _day, _mon = int(_m.group(1)), _months[_m.group(2)] _cand = date(today.year, _mon, _day) if _cand < today: _cand = date(today.year + 1, _mon, _day) target_date = _cand current_text_clean = (current_text_clean[:_m.start()] + current_text_clean[_m.end():]) except ValueError: pass # Убираем остаточные контекстные слова чтобы они не попали в заголовок current_text_clean = re.sub(r'\bчерез\b', '', current_text_clean) current_text_clean = re.sub(r'\bдо\b', '', current_text_clean) date_str = target_date.strftime("%d.%m.%Y") if target_date else None final_title = current_text_clean.strip() final_title = re.sub(r'\s+', ' ', final_title) if final_title: final_title = final_title[0].upper() + final_title[1:] else: final_title = text return final_title, priority, date_str, assignee except Exception as e: logging.error(f"Error in parse_voice_fuzzy: {e}") return text, None, None, None # === СОБЫТИЯ EVENT BUS === def reload_assignees(self, data=None): self.load_assignees_to_ui() def load_assignees_to_ui(self): try: managers = self.db.get_employees(only_managers=True) manager_names = sorted([e['name'] for e in managers]) if hasattr(self.view, 'filter_assignee'): self.view.filter_assignee['values'] = ["Все"] + manager_names if hasattr(self.view, 'combo_assignee'): self.view.combo_assignee['values'] = manager_names except Exception as e: logging.error(f"Error loading assignees: {e}") # === CRUD ОПЕРАЦИИ === def add_root(self): d = self.view.get_input_data() if d: iso_date = self.date_ui_to_db(d[3]) new_id = self.db.add_task(d[0], d[1], d[2], iso_date, TaskStatus.IN_PROGRESS, 0, "") self.add_undo('create', new_id) threading.Thread(target=self._send_tg_safe, args=(new_id, d[0], d[1], d[3], d[2]), daemon=True).start() self.load_tasks(); self.view.clear_inputs() def add_sub(self): sel = self.view.tree.selection() if not sel: return d = self.view.get_input_data() if d: pid = int(sel[0]) iso_date = self.date_ui_to_db(d[3]) new_id = self.db.add_task(pid, d[0], d[1], d[2], iso_date, TaskStatus.IN_PROGRESS, "") self.add_undo('create', new_id) threading.Thread(target=self._send_tg_safe, args=(new_id, d[0], d[1], d[3], d[2]), daemon=True).start() self.view.tree.item(str(pid), open=True) self.load_tasks(); self.view.clear_inputs() def load_tasks(self): try: expanded = self.view.get_expanded_items() for i in self.view.tree.get_children(): self.view.tree.delete(i) all_tasks_raw = [dict(t) for t in self.db.get_all_tasks()] self.update_stats(all_tasks_raw) filtered = self.filter_tasks(all_tasks_raw) tree_map = {} roots = [] all_ids = set(str(t['id']) for t in filtered) for t in filtered: pid = t['parent_id'] if pid == 0 or pid is None: roots.append(t) elif str(pid) not in all_ids: roots.append(t) else: tree_map.setdefault(pid, []).append(t) if self.sort_col: def get_sort_key(task): val = task.get(self.sort_col, "") if val is None: return "" if self.sort_col == 'due_date': return val if self.sort_col == 'id': return int(val) return str(val).lower() roots.sort(key=get_sort_key, reverse=self.sort_reverse) for pid in tree_map: tree_map[pid].sort(key=get_sort_key, reverse=self.sort_reverse) counter = [1] def insert(task, level=0): d_disp = self.date_db_to_ui(task['due_date']) tag = ''; icon_prefix = '' is_done = "Выполнено" in task['status'] if is_done: tag = 'completed' else: try: task_date = datetime.strptime(task['due_date'], "%Y-%m-%d").date() today = datetime.now().date() if task_date < today: tag = 'overdue'; icon_prefix = "🔥 " elif task_date == today: tag = 'today'; icon_prefix = "⚠ " except Exception: pass days_in_status = 0 try: ts = task['updated_at'] if task['updated_at'] else task['created_at'] if ts: days_in_status = self.calculate_workdays(ts) except Exception: pass def _fmt_ts(raw): if not raw: return '' for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M'): try: return datetime.strptime(raw, fmt).strftime('%d.%m.%Y %H:%M') except ValueError: pass return str(raw) poll_sent_disp = _fmt_ts(task.get('poll_sent_at')) poll_answered_disp = _fmt_ts(task.get('poll_answered_at')) vals = (task['title'], task['priority'], task['assignee'], d_disp, poll_sent_disp, task['status'], poll_answered_disp, str(days_in_status), task['comment']) num_str = f"{icon_prefix}{counter[0]}" counter[0] += 1 sid = str(task['id']) pid_str = str(task['parent_id']) if task['parent_id'] else "" self.view.tree.insert( pid_str if pid_str in all_ids else "", "end", iid=sid, text=num_str, values=vals, tags=(tag,) ) if sid in expanded: self.view.tree.item(sid, open=True) if task['id'] in tree_map: for child in tree_map[task['id']]: insert(child, level + 1) for r in roots: insert(r) except Exception as e: logging.error(f"Error loading tasks: {e}", exc_info=True) def update_status(self, task_id, new_status): try: self.db.update_task(task_id, status=new_status) self.load_tasks() except Exception as e: messagebox.showerror("Ошибка", f"Не удалось обновить статус: {e}") logging.error(f"Error updating status: {e}") def filter_tasks(self, tasks): try: f_date = self.view.filter_date.get() f_status = self.view.filter_status.get() f_assignee = self.view.filter_assignee.get() f_prio = self.view.filter_prio.get() f_search = self.view.filter_search.get().lower().strip() res = [] today = datetime.now().date() for t in tasks: if f_search and f_search not in t['title'].lower(): continue # ИСПРАВЛЕНО: Показываем выполненные задачи если фильтр = "Все" # Старая логика скрывала Done если не выбран явно статус Done if f_status != "Все": # Если выбран конкретный статус - показываем только его if f_status not in t['status']: continue # Если фильтр = "Все" - показываем ВСЕ задачи включая Done pass_date = True try: d_obj = datetime.strptime(t['due_date'], "%Y-%m-%d").date() if f_date == "Сегодня" and d_obj != today: pass_date = False elif f_date == "Завтра" and d_obj != (today + timedelta(days=1)): pass_date = False elif f_date == "Просрочено" and d_obj >= today: pass_date = False except Exception: if f_date != "Все": pass_date = False if not pass_date: continue if f_assignee != "Все" and f_assignee != t['assignee']: continue if f_prio != "Все" and f_prio != t['priority']: continue res.append(t) return res except Exception as e: logging.error(f"Filter error: {e}") return tasks def update_stats(self, tasks): try: cnt_overdue = 0; cnt_today = 0; cnt_high = 0; cnt_total_unfin = 0 today = datetime.now().date() for t in tasks: is_done = "Выполнено" in t['status'] if not is_done: cnt_total_unfin += 1 if "Высокий" in t['priority']: cnt_high += 1 try: d_obj = datetime.strptime(t['due_date'], "%Y-%m-%d").date() if d_obj < today: cnt_overdue += 1 elif d_obj == today: cnt_today += 1 except Exception: pass self.view.lbl_stat_overdue.config(text=f"⏳ {cnt_overdue}") self.view.lbl_stat_today.config(text=f"⚠ {cnt_today}") self.view.lbl_stat_high.config(text=f"🔥 {cnt_high}") self.view.lbl_stat_total.config(text=f"📥 {cnt_total_unfin}") except Exception as e: logging.error(f"Stats error: {e}") def sort_tasks(self, col): col_map = {"title": "title", "prio": "priority", "assignee": "assignee", "date": "due_date", "status": "status", "comment": "comment", "#0": "id"} db_col = col_map.get(col, col) if self.sort_col == db_col: self.sort_reverse = not self.sort_reverse else: self.sort_col = db_col; self.sort_reverse = False self.load_tasks() def edit_load(self): sel = self.view.tree.selection() if sel: self.editing_item_id = sel[0] vals = self.view.tree.item(sel[0], "values") self.view.set_inputs(vals[0], vals[1], vals[2], vals[3].strip()) self.view.btn_update.config(state="normal") def edit_save(self): if self.editing_item_id: d = self.view.get_input_data() if d: tid = int(self.editing_item_id) new_date_iso = self.date_ui_to_db(d[3]) old = self.db.get_task_by_id(tid) self.add_undo('update', old) old_vals = self.view.tree.item(tid, "values") current_comment = old_vals[6] if len(old_vals) > 6 else "" self.db.update_task(tid, d[0], d[1], d[2], new_date_iso, old_vals[4], current_comment) try: self.sync_dates_hierarchy(tid, new_date_iso) except Exception as e: logging.error(f"Date sync error: {e}") self.load_tasks(); self.editing_item_id = None; self.view.btn_update.config(state="disabled"); self.view.clear_inputs() def sync_dates_hierarchy(self, task_id, new_date_str): new_date = datetime.strptime(new_date_str, "%Y-%m-%d").date() children = self.db.get_children(task_id) for child in children: child_date = datetime.strptime(child['due_date'], "%Y-%m-%d").date() if child_date > new_date: self.db.update_task_date(child['id'], new_date_str) current_task = self.db.get_task_by_id(task_id) if current_task['parent_id']: parent = self.db.get_task_by_id(current_task['parent_id']) parent_date = datetime.strptime(parent['due_date'], "%Y-%m-%d").date() if parent_date < new_date: self.db.update_task_date(parent['id'], new_date_str) def delete(self): sel = self.view.tree.selection() if sel and messagebox.askyesno("Подтверждение удаления", "Вы уверены?"): self.db.delete_task(int(sel[0])); self.load_tasks() def undo(self): if not self.undo_stack: return act = self.undo_stack.pop(); t = act['type']; d = act['data'] if t == 'create': self.db.delete_task(d) elif t == 'update': self.db.update_task(d['id'], d['title'], d['priority'], d['assignee'], d['due_date'], d['status'], d['comment']) self.load_tasks() def add_undo(self, type_, data): self.undo_stack.append({'type': type_, 'data': data}) if len(self.undo_stack) > 20: self.undo_stack.pop(0) def date_ui_to_db(self, date_str): try: return datetime.strptime(date_str, "%d.%m.%Y").strftime("%Y-%m-%d") except Exception: return date_str def date_db_to_ui(self, date_iso): try: return datetime.strptime(date_iso, "%Y-%m-%d").strftime("%d.%m.%Y") except Exception: return date_iso def calculate_workdays(self, start_iso): if not start_iso: return 0 try: if " " in start_iso: start_iso = start_iso.split(" ")[0] start_date = datetime.fromisoformat(start_iso).date() end_date = datetime.now().date() if start_date >= end_date: return 0 workdays = 0; curr = start_date while curr < end_date: if self.is_working_day(curr): workdays += 1 curr += timedelta(days=1) return workdays except Exception: return 0 def open_comment_dialog(self): sel = self.view.tree.selection() if not sel: return task_id = int(sel[0]); task = self.db.get_task_by_id(task_id) if not task: return win_title = f"💬 Комментарий: {task['title'][:30]}..." CommentDialog(self.root, win_title, task['comment'], lambda text: self.save_comment(task_id, text)) def save_comment(self, task_id, text): self.db.update_task_comment(task_id, text); self.load_tasks() def set_status(self, s): """Изменить статус задачи""" sel = self.view.tree.selection() if not sel: return task_id = int(sel[0]) self.db.update_task_status(task_id, s) try: children = self.db.get_children(task_id) for child in children: self.db.update_task_status(child['id'], s) except Exception as e: logging.error(f"Status sync error: {e}") self.load_tasks() def move_task(self, s, t): try: s_id = int(s); t_id = int(t) if messagebox.askyesno("Перенос", "Переместить задачу?"): self.db.update_task_parent(s_id, t_id); self.load_tasks() except Exception: pass def reset_filter(self): self.view.filter_date.current(0); self.view.filter_status.current(0); self.view.filter_assignee.current(0); self.view.filter_prio.current(0); self.view.filter_search.delete(0, 'end'); self.load_tasks() def open_emp(self): messagebox.showinfo("Инфо", "Перейдите на вкладку 'Сотрудники'") def shift_date(self): sel = self.view.tree.selection() if not sel: return tid = int(sel[0]); task = self.db.get_task_by_id(tid) try: current_date_obj = datetime.strptime(task['due_date'], "%Y-%m-%d").date() next_date = current_date_obj + timedelta(days=1) for _ in range(30): if self.is_working_day(next_date): break next_date += timedelta(days=1) new_date_str = next_date.strftime("%Y-%m-%d") self.db.update_task(task['id'], task['title'], task['priority'], task['assignee'], new_date_str, task['status'], task['comment']) try: self.sync_dates_hierarchy(task['id'], new_date_str) except Exception as e: logging.error(f"Date sync error (shift): {e}") self.load_tasks() try: self.view.set_status(f"Срок сдвинут на {next_date.strftime('%d.%m.%Y')}") except Exception: pass except Exception as e: logging.error(f"Shift date error: {e}") def is_working_day(self, date_obj): iso_date = date_obj.strftime("%Y-%m-%d") try: exception = self.db.get_calendar_exception(iso_date) if exception: return bool(exception['is_working']) except AttributeError: return True if date_obj.weekday() >= 5: return False return True def tg_send(self): if not self.tg.token: token = simpledialog.askstring("Настройка Telegram", "Введите токен бота (из @BotFather):", parent=self.root) if token: self.db.set_setting("bot_token", token); self.tg.set_token(token); messagebox.showinfo("Telegram", "Токен сохранен!") else: return sel = self.view.tree.selection() if not sel: return vals = self.view.tree.item(sel[0], "values"); emp_name = vals[2] emp = self.db.get_employee_by_name(emp_name) if not emp: messagebox.showerror("Ошибка", f"Сотрудник '{emp_name}' не найден в базе!"); return if not emp.tg_id: messagebox.showerror("Ошибка", f"У сотрудника '{emp_name}' не заполнен Telegram ID (вкладка Сотрудники)!"); return threading.Thread(target=lambda: self.tg.send_poll_one(int(sel[0]), vals[0], vals[1], vals[3], vals[2]), daemon=True).start() def tg_send_all(self): pass def tg_sync(self): """Открыть диалог настройки рассылок TG""" TGSettingsDialog(self.root, self.db) # ── Авто-синхронизация TG ───────────────────────────────────────────── def _auto_sync_loop(self): """Фоновый поток: синхронизация при старте, затем каждый час.""" time.sleep(5) # дать UI инициализироваться while True: try: self._do_auto_sync() except Exception as e: logging.error(f"TG auto-sync loop error: {e}", exc_info=True) time.sleep(3600) # 1 час def _drain_reload_queue(self): """Главный поток: читает сигналы перезагрузки от TG-потоков.""" try: while True: self._reload_queue.get_nowait() self.load_tasks() except queue.Empty: pass self.root.after(500, self._drain_reload_queue) def _do_auto_sync(self): """Получить новые ответы на опросы; при необходимости — повторно отправить.""" # 1. Синхронизируем ответы done_event = threading.Event() def _on_sync(count): if count > 0: # Нельзя вызывать root.after() из фонового потока → используем очередь self._reload_queue.put(1) done_event.set() self.tg.sync_status(callback=_on_sync) done_event.wait(timeout=30) # ждём завершения (макс 30 с) # 2. Повторная отправка и автоотправка по срокам (только в рабочие дни) try: today = datetime.now().date() if not self.is_working_day(today): return rules = self.db.get_tg_rules() resend_on = any( r['rule_type'] == 'Повторная отправка' and r['is_enabled'] for r in rules ) if resend_on: self._resend_unanswered_polls() self._send_deadline_polls(rules, today) except Exception as e: logging.error(f"TG resend error: {e}", exc_info=True) def _resend_unanswered_polls(self): """Повторно отправить опросы по задачам, не получившим ответа.""" tasks = self.db.get_unanswered_poll_tasks() if not tasks: return header = ( "ВНИМАНИЕ! Статус по данной задаче вами не был обновлен. " "Необходимо его обновить, пройдя опрос." ) for task in tasks: try: d_disp = self.date_db_to_ui(task.get('due_date', '')) sent = self.tg.send_poll_one( task['id'], task['title'], task['priority'], d_disp, task['assignee'] or '', header_text=header, show_error=False, ) if sent: logging.info(f"Resent poll for task {task['id']} ({task['title']})") except Exception as e: logging.error(f"Resend poll error task {task['id']}: {e}") def _send_deadline_polls(self, rules, today): """Автоматическая отправка опросов по правилам 'Автоотправка по срокам'.""" deadline_rules = { r['subject']: r for r in rules if r['rule_type'] == 'Автоотправка по срокам' and r['is_enabled'] } if not deadline_rules: return tasks = self.db.get_tasks_for_deadline_check() if not tasks: return today_str = today.strftime('%Y-%m-%d') for task in tasks: try: due_str = task.get('due_date', '') if not due_str: continue due = datetime.strptime(due_str, '%Y-%m-%d').date() # Пропускаем, если опрос уже отправлен сегодня sent_at = task.get('poll_sent_at') or '' if sent_at and sent_at[:10] == today_str: continue d_disp = self.date_db_to_ui(due_str) header = None # Определяем, какое правило срабатывает if due > today: # За 3 рабочих дня three_days_before = self.get_working_days_before(due, 3) if today == three_days_before and 'За 3 рабочих дня' in deadline_rules: header = None # без дополнительного заголовка rule_key = 'За 3 рабочих дня' else: continue elif due == today: # В день истечения срока if 'В день истечения срока' in deadline_rules: header = "ВНИМАНИЕ! Сегодня по данной задаче истекает срок!" rule_key = 'В день истечения срока' else: continue else: # После истечения срока (due < today) if 'После истечения срока' in deadline_rules: header = ( "ВНИМАНИЕ! СРОК ПО ЭТОЙ ЗАДАЧЕ ИСТЕК! " "НЕОБХОДИМО СВЯЗАТЬСЯ С РУКОВОДСТВОМ!" ) rule_key = 'После истечения срока' else: continue sent = self.tg.send_poll_one( task['id'], task['title'], task['priority'], d_disp, task.get('assignee') or '', header_text=header, show_error=False, ) if sent: logging.info( f"Deadline poll [{rule_key}] sent for task {task['id']} " f"({task['title']}, due {due_str})" ) except Exception as e: logging.error(f"Deadline poll error task {task.get('id')}: {e}") def export(self): try: tasks = self.db.get_all_tasks() if not tasks: return df = pd.DataFrame([dict(t) for t in tasks]) filename = filedialog.asksaveasfilename(defaultextension=".xlsx", filetypes=[("Excel", "*.xlsx")]) if filename: df.to_excel(filename, index=False) except Exception as e: messagebox.showerror("Ошибка", f"{e}")