From 30cb3f66bf9d47f9fadcb858abae1e6176960373 Mon Sep 17 00:00:00 2001 From: DokoGen Date: Thu, 30 Jul 2026 14:38:47 +0400 Subject: [PATCH] =?UTF-8?q?fix:=208=20=D0=B1=D0=B0=D0=B3=D0=BE=D0=B2=20?= =?UTF-8?q?=E2=80=94=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4,=20IS=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8F,=20=D0=9F=D0=9E,=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=87=D1=91=D1=82,=20=D1=81=D0=BA=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB,=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81c,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B8=D1=81=D1=81=D0=B8=D1=8F,=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dokogen/generator.py | 64 ++++++++++++++-- dokogen/importer.py | 7 +- dokogen/models.py | 1 - dokogen/ui.py | 174 +++++++++++++++++++++++++++++++++++++------ dokogen/variables.py | 2 - 5 files changed, 210 insertions(+), 38 deletions(-) diff --git a/dokogen/generator.py b/dokogen/generator.py index d312901..717a36f 100644 --- a/dokogen/generator.py +++ b/dokogen/generator.py @@ -57,16 +57,19 @@ def process_template( buf.seek(0) doc = Document(buf) - # 5. Плоская замена переменных (2 прохода) - for _ in range(2): + # 5. Плоская замена переменных (3 прохода) + for _ in range(3): _replace_in_paragraphs(doc.paragraphs, replacements) _replace_in_tables(doc.tables, replacements) _replace_in_headers_footers(doc, replacements) - # 6. Подсветка незаполненных + # 6. Логирование незаменённых переменных + _log_unmatched(doc, replacements, log_func if log_func else lambda x: None) + + # 7. Подсветка незаполненных _highlight_unmatched(doc) - # 7. Сохранение + # 8. Сохранение doc.save(output_path) if log_func: @@ -338,12 +341,57 @@ def _find_element_bounds(xml_str: str, pos: int): return None, None +# ==================== ЛОГИРОВАНИЕ НЕЗАМЕНЁННЫХ ==================== + +UNMATCHED_PATTERN = re.compile(r'\{\{[^}]+\}\}|<[A-Za-z0-9_]+>') + + +def _log_unmatched(doc, replacements: Dict[str, str], log_func): + """Логирует оставшиеся незаменённые переменные.""" + unmatched = set() + + def _scan_text(text): + for m in UNMATCHED_PATTERN.finditer(text): + var = m.group(0) + # Проверяем, не является ли это уже обработанной переменной + if var.startswith('{{'): + if var not in replacements: + unmatched.add(var) + elif var.startswith('<'): + if var not in replacements: + unmatched.add(var) + + for para in doc.paragraphs: + _scan_text(para.text) + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + _scan_text(para.text) + for section in doc.sections: + for hf in [section.header, section.footer, + section.first_page_header, section.first_page_footer]: + if hf: + for para in hf.paragraphs: + _scan_text(para.text) + + if unmatched: + log_func(f"⚠️ Незаменённые переменные ({len(unmatched)}): {', '.join(sorted(unmatched)[:20])}") + if len(unmatched) > 20: + log_func(f" ... и ещё {len(unmatched) - 20}") + else: + log_func("✅ Все переменные успешно заменены") + + # ==================== ПОДСВЕТКА НЕЗАПОЛНЕННЫХ ==================== def _highlight_unmatched(doc): - """Подсвечивает незаполненные переменные красным.""" + """Подсвечивает незаполненные переменные красным ({{...}} и старые <...>).""" ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' + def _has_unmatched(text): + return bool(UNMATCHED_PATTERN.search(text)) + def _highlight_run(run): rpr = run._element.find(f'{{{ns}}}rPr') if rpr is None: @@ -358,7 +406,7 @@ def _highlight_unmatched(doc): for para in doc.paragraphs: for run in para.runs: - if '{{' in run.text and '}}' in run.text: + if _has_unmatched(run.text): _highlight_run(run) for table in doc.tables: @@ -366,7 +414,7 @@ def _highlight_unmatched(doc): for cell in row.cells: for para in cell.paragraphs: for run in para.runs: - if '{{' in run.text and '}}' in run.text: + if _has_unmatched(run.text): _highlight_run(run) for section in doc.sections: @@ -374,5 +422,5 @@ def _highlight_unmatched(doc): if hf: for para in hf.paragraphs: for run in para.runs: - if '{{' in run.text and '}}' in run.text: + if _has_unmatched(run.text): _highlight_run(run) diff --git a/dokogen/importer.py b/dokogen/importer.py index f5cc115..d306538 100644 --- a/dokogen/importer.py +++ b/dokogen/importer.py @@ -173,7 +173,7 @@ def _import_152_is_list(wb, data, log_fn): is_obj = { 'name': '', 'description': '', 'room': '', 'employee': '', 'position': '', - 'software': '', 'personalDataList': [], 'pd_subjects_list': [], 'pd_actions': [], + 'personalDataList': [], 'pd_subjects_list': [], 'pd_actions': [], 'usersList': [], 'isInternet': False, 'personalDataCount': 'менее 100 000', 'defense_tools': [], 'processing_mode': '', } @@ -232,8 +232,6 @@ def _import_152_is_list(wb, data, log_fn): pass elif 'номер кабинета' in field_lower: is_obj['room'] = val_b - elif 'названия программ' in field_lower or ('программ' in field_lower and 'перечень' not in field_lower): - is_obj['software'] = val_b elif 'значение' in field_lower or 'примечание' in field_lower: continue elif val_b and 'выбрать' not in field_lower: @@ -302,9 +300,6 @@ def _import_152_is_list(wb, data, log_fn): is_obj['name'] = sheet_name.strip() log_fn(f" ⚠ ИС без названия — использовано имя листа: {is_obj['name']}") - if is_obj['software'] and not is_obj['description']: - is_obj['description'] = f"Используемое ПО: {is_obj['software']}" - # Автоопределение категорий ПДн pd_cats = [] all_pd_text = ' '.join(p.lower() for p in pd_items_raw) diff --git a/dokogen/models.py b/dokogen/models.py index 18ebe6c..dfd273d 100644 --- a/dokogen/models.py +++ b/dokogen/models.py @@ -18,7 +18,6 @@ class InformationSystem: """Информационная система (обработка ПДн)""" name: str = "" description: str = "" - software: str = "" is_local_network: bool = False is_internet: bool = False defence_level: str = "" diff --git a/dokogen/ui.py b/dokogen/ui.py index 98f27e8..b61666f 100644 --- a/dokogen/ui.py +++ b/dokogen/ui.py @@ -201,7 +201,7 @@ class DokoGenApp: addr_fields = [ ("Юридический адрес:", "address"), - ("Город:", "city_name"), + ("Город (заполняется автоматически):", "city_name"), ("ИНН:", "inn"), ("КПП:", "kpp"), ("ОГРН:", "ogrn"), @@ -212,6 +212,12 @@ class DokoGenApp: entry = ttk.Entry(f2, width=50) entry.grid(row=i, column=1, padx=10, sticky=tk.EW, pady=1) self._company_entries[key] = entry + + # Привязка автозаполнения города из адреса + addr_entry = self._company_entries.get('address') + if addr_entry: + addr_entry.bind('', self._on_address_changed) + f2.columnconfigure(1, weight=1) # --- Должностные лица --- @@ -278,13 +284,9 @@ class DokoGenApp: self.is_description_entry = ttk.Entry(fields_frame, width=55) self.is_description_entry.grid(row=1, column=1, padx=10, sticky=tk.EW) - ttk.Label(fields_frame, text="ПО:").grid(row=2, column=0, sticky=tk.W, pady=2) - self.is_software_entry = ttk.Entry(fields_frame, width=55) - self.is_software_entry.grid(row=2, column=1, padx=10, sticky=tk.EW) - # Чекбоксы chk_frame = ttk.Frame(fields_frame) - chk_frame.grid(row=3, column=1, sticky=tk.W, pady=5) + chk_frame.grid(row=2, column=1, sticky=tk.W, pady=5) self.is_lan_var = tk.BooleanVar() ttk.Checkbutton(chk_frame, text="ЛВС", variable=self.is_lan_var).pack(side=tk.LEFT, padx=5) self.is_internet_var = tk.BooleanVar() @@ -292,33 +294,42 @@ class DokoGenApp: # Тип угроз self.is_threat_var = tk.StringVar(value="3") - ttk.Label(fields_frame, text="Тип угроз:").grid(row=4, column=0, sticky=tk.W, pady=2) + ttk.Label(fields_frame, text="Тип угроз:").grid(row=3, column=0, sticky=tk.W, pady=2) threat_frame = ttk.Frame(fields_frame) - threat_frame.grid(row=4, column=1, sticky=tk.W, padx=10) + threat_frame.grid(row=3, column=1, sticky=tk.W, padx=10) for val, text in [("1", "1 (НДВ в СПО)"), ("2", "2 (НДВ в ППО)"), ("3", "3 (НДВ нет)")]: ttk.Radiobutton(threat_frame, text=text, variable=self.is_threat_var, value=val).pack(side=tk.LEFT, padx=3) # Уровень защищённости self.is_defence_var = tk.StringVar(value="4") - ttk.Label(fields_frame, text="Ур. защищённости:").grid(row=5, column=0, sticky=tk.W, pady=2) + ttk.Label(fields_frame, text="Ур. защищённости:").grid(row=4, column=0, sticky=tk.W, pady=2) def_frame = ttk.Frame(fields_frame) - def_frame.grid(row=5, column=1, sticky=tk.W, padx=10) + def_frame.grid(row=4, column=1, sticky=tk.W, padx=10) for lvl in ["1", "2", "3", "4"]: ttk.Radiobutton(def_frame, text=f"{lvl} ур.", variable=self.is_defence_var, value=lvl).pack(side=tk.LEFT, padx=3) + ttk.Button(def_frame, text="Рассчитать автоматически", + command=self._auto_calc_defence_level).pack(side=tk.LEFT, padx=10) # Категории ПДн - ttk.Label(fields_frame, text="Категории ПДн:").grid(row=6, column=0, sticky=tk.NW, pady=2) + ttk.Label(fields_frame, text="Категории ПДн:").grid(row=5, column=0, sticky=tk.NW, pady=2) cat_frame = ttk.Frame(fields_frame) - cat_frame.grid(row=6, column=1, sticky=tk.W, padx=10) + cat_frame.grid(row=5, column=1, sticky=tk.W, padx=10) self.category_vars = [] for cat in ["специальные", "биометрические", "общедоступные", "иные"]: var = tk.BooleanVar() + var.trace_add('write', lambda *_: self._on_categories_or_count_changed()) cb = ttk.Checkbutton(cat_frame, text=cat, variable=var) cb.pack(anchor=tk.W) self.category_vars.append((cat, var)) + # Количество записей ПДн + ttk.Label(fields_frame, text="Кол-во записей:").grid(row=6, column=0, sticky=tk.W, pady=2) + self.is_pd_count_entry = ttk.Entry(fields_frame, width=30) + self.is_pd_count_entry.grid(row=6, column=1, padx=10, sticky=tk.W) + self.is_pd_count_entry.bind('', lambda e: self._on_categories_or_count_changed()) + fields_frame.columnconfigure(1, weight=1) # Вложенные списки @@ -405,7 +416,17 @@ class DokoGenApp: tab = ttk.Frame(self.notebook) self.notebook.add(tab, text="Пользователи") - frame = ttk.Frame(tab, padding=10) + canvas = tk.Canvas(tab, highlightthickness=0) + scrollbar = ttk.Scrollbar(tab, orient=tk.VERTICAL, command=canvas.yview) + scrollable = ttk.Frame(canvas) + scrollable.bind("", + lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=scrollable, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + frame = ttk.Frame(scrollable, padding=10) frame.pack(fill=tk.BOTH, expand=True) ttk.Label(frame, text="Сотрудники, имеющие доступ к ПДн", @@ -572,13 +593,14 @@ class DokoGenApp: isys = self.model.information_systems[self._current_is_idx] isys.name = self.is_name_entry.get().strip() isys.description = self.is_description_entry.get().strip() - isys.software = self.is_software_entry.get().strip() isys.is_local_network = self.is_lan_var.get() isys.is_internet = self.is_internet_var.get() isys.threat_type = self.is_threat_var.get() isys.defence_level = self.is_defence_var.get() cats = [c for c, v in self.category_vars if v.get()] isys.personal_data_category = cats + if hasattr(self, 'is_pd_count_entry'): + isys.pd_count = self.is_pd_count_entry.get().strip() or 'менее 100 000' # Списки isys.personal_data_list = list(self.pd_listbox.get(0, tk.END)) isys.pd_subjects_list = list(self.pd_subjects_listbox.get(0, tk.END)) @@ -657,6 +679,92 @@ class DokoGenApp: # Шаблоны self._on_templates_changed() + # Автоопределение города из адреса после загрузки + self._on_address_changed() + + # ==================== ГОРОД ИЗ АДРЕСА ==================== + def _extract_city_from_address(self, address: str) -> str: + """Извлекает название города из адреса (простая эвристика).""" + if not address: + return '' + # Пробуем найти "г. Город" + import re + m = re.search(r'\bг\.\s*([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address) + if m: + return m.group(1).strip() + # Пробуем найти "город Город" + m = re.search(r'\bгород\s+([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address) + if m: + return m.group(1).strip() + # Пробуем первую часть до запятой, если там известный город + parts = [p.strip() for p in address.split(',')] + if parts: + first = parts[0] + # Если первая часть — короткая и не похожа на улицу/дом + if len(first) <= 25 and first and not first.startswith(('ул', 'пр', 'пер', 'шоссе')): + # Проверяем, что это похоже на город + city_keywords = ['ск', 'цк', 'бург', 'град', 'полис', 'поль', 'стан', 'горск', 'чинск', 'янск', 'овск'] + if any(first.lower().endswith(kw) for kw in city_keywords): + return first + return '' + + def _on_address_changed(self, event=None): + """Обработчик изменения адреса — автозаполняет город.""" + addr_entry = self._company_entries.get('address') + city_entry = self._company_entries.get('city_name') + if not addr_entry or not city_entry: + return + address = addr_entry.get().strip() + if not address: + return + current_city = city_entry.get().strip() + if current_city: + return # Не перезаписываем, если уже заполнен + city = self._extract_city_from_address(address) + if city: + city_entry.delete(0, tk.END) + city_entry.insert(0, city) + self.log(f"🏙 Город определён: {city}") + + # ==================== АВТОРАСЧЁТ УРОВНЯ ЗАЩИЩЁННОСТИ ==================== + def _auto_calc_defence_level(self): + """Автоматический расчёт уровня защищённости на основе категорий ПДн и кол-ва записей.""" + if self._current_is_idx is None: + return + + # Считываем категории + categories = [cat for cat, var in self.category_vars if var.get()] + pd_count_text = self.is_pd_count_entry.get().strip() if hasattr(self, 'is_pd_count_entry') else '' + + # Определяем базовый уровень (4 — самый низкий, по умолчанию) + level = 4 + + # Специальные или биометрические → минимум 3 уровень + has_special = any(c in categories for c in ['специальные', 'биометрические']) + if has_special: + level = 3 + + # Больше 100 000 записей → повышаем уровень на 1 (уровень становится строже) + if pd_count_text: + # Парсим число + import re + nums = re.findall(r'\d+', pd_count_text.replace(' ', '')) + if nums: + count = int(nums[0]) + if count > 100000: + level = max(1, level - 1) # Уровень повышается (число уменьшается) + # Также проверяем текстовые варианты + if 'более' in pd_count_text.lower() or 'свыше' in pd_count_text.lower(): + level = max(1, level - 1) + + self.is_defence_var.set(str(level)) + self.log(f"🛡 Уровень защищённости автоматически рассчитан: {level}") + + def _on_categories_or_count_changed(self): + """Обработчик изменения категорий ПДн или количества записей.""" + if self._current_is_idx is not None: + self._auto_calc_defence_level() + # ==================== КОМИССИЯ ==================== def _add_commission_row(self, role="Член комиссии", position="", fio=""): row = ttk.Frame(self._comm_members_frame) @@ -665,7 +773,7 @@ class DokoGenApp: role_var = tk.StringVar(value=role) role_cb = ttk.Combobox(row, textvariable=role_var, state='readonly', width=22, values=["Председатель комиссии", "Секретарь комиссии", - "Член комиссии", "Заместитель председателя"]) + "Член комиссии"]) role_cb.pack(side=tk.LEFT, padx=2) pos_entry = ttk.Entry(row, width=30) @@ -702,8 +810,6 @@ class DokoGenApp: def _set_is_edit_state(self, enabled): state = 'normal' if enabled else 'disabled' - for w in [self.is_name_entry, self.is_description_entry, self.is_software_entry]: - w.config(state=state) self.is_lan_var.set(False) self.is_internet_var.set(False) @@ -726,14 +832,16 @@ class DokoGenApp: self.is_name_entry.insert(0, isys.name or "") self.is_description_entry.delete(0, tk.END) self.is_description_entry.insert(0, isys.description or "") - self.is_software_entry.delete(0, tk.END) - self.is_software_entry.insert(0, isys.software or "") self.is_lan_var.set(isys.is_local_network) self.is_internet_var.set(isys.is_internet) self.is_threat_var.set(isys.threat_type or "3") self.is_defence_var.set(isys.defence_level or "4") for cat, var in self.category_vars: var.set(cat in isys.personal_data_category) + # Заполняем кол-во записей ПДн + if hasattr(self, 'is_pd_count_entry'): + self.is_pd_count_entry.delete(0, tk.END) + self.is_pd_count_entry.insert(0, isys.pd_count or 'менее 100 000') # Списки for lb, items in [ @@ -765,7 +873,6 @@ class DokoGenApp: copy = InformationSystem( name=src.name + " (копия)", description=src.description, - software=src.software, is_local_network=src.is_local_network, is_internet=src.is_internet, defence_level=src.defence_level, @@ -978,6 +1085,27 @@ class DokoGenApp: os.makedirs(output_dir, exist_ok=True) + # Окно прогресса + progress_win = tk.Toplevel(self.root) + progress_win.title("Генерация документов") + progress_win.geometry("350x100") + progress_win.transient(self.root) + progress_win.grab_set() + progress_win.resizable(False, False) + ttk.Label(progress_win, text="Генерация документов...", + font=('Arial', 10)).pack(pady=(15, 5)) + pb = ttk.Progressbar(progress_win, mode='indeterminate', length=300) + pb.pack(pady=5) + pb.start() + + def _close_progress(): + try: + pb.stop() + progress_win.grab_release() + progress_win.destroy() + except Exception: + pass + # Запускаем в отдельном потоке def _run(): try: @@ -1006,12 +1134,14 @@ class DokoGenApp: self.log(f"❌ {fname}: {e}") self.log(f"✅ Готово: {len(results)} из {len(selected)}") + self.root.after(0, _close_progress) self.root.after(0, lambda: messagebox.showinfo( "Готово", f"Сгенерировано {len(results)} документов\n" f"Папка: {output_dir}" )) except Exception as e: self.log(f"❌ Ошибка: {e}") + self.root.after(0, _close_progress) self.root.after(0, lambda: messagebox.showerror("Ошибка", str(e))) threading.Thread(target=_run, daemon=True).start() @@ -1114,6 +1244,9 @@ class DokoGenApp: entry.delete(0, tk.END) entry.insert(0, val) + # Автоопределение города из адреса после импорта + self._on_address_changed() + # Должностные лица (маппинг) officials_map = { 'chief_position': 'chiefPosition', @@ -1152,7 +1285,6 @@ class DokoGenApp: isys = InformationSystem( name=is_data.get('name', ''), description=is_data.get('description', ''), - software=is_data.get('software', ''), is_internet=is_data.get('isInternet', False), pd_count=is_data.get('personalDataCount', 'менее 100 000'), personal_data_list=is_data.get('personalDataList', []), diff --git a/dokogen/variables.py b/dokogen/variables.py index 45af1fb..e7d7d0a 100644 --- a/dokogen/variables.py +++ b/dokogen/variables.py @@ -169,7 +169,6 @@ def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int): 'is_users': ', '.join(isys.users_list or []) or '—', 'is_pd_count': str(isys.pd_count or '—'), 'is_purpose': isys.purpose or '—', - 'is_software_list': isys.software or '—', } for f, v in fields.items(): replacements[f'{{{{{f}_{idx}}}}}'] = str(v) @@ -198,7 +197,6 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]: items.append({ 'name': isys.name or '—', 'description': isys.description or '—', - 'software': isys.software or '—', 'is_has_lan': 'Да' if isys.is_local_network else 'Нет', 'has_internet': 'да' if isys.is_internet else 'нет', 'defence_level': str(isys.defence_level or '—'),