commit 0ddcb2401b56923638c09e36a8ae5a0282b6b4fa Author: DokoGen Date: Thu Jul 30 16:52:57 2026 +0400 fix: unpack 4→3 в _collect_employees (is_entry удалён) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01aac8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.pyc +__pycache__/ +.autosave/ +Сгенерированные_документы/ +*.result.docx diff --git a/dokogen/__init__.py b/dokogen/__init__.py new file mode 100644 index 0000000..a55ba29 --- /dev/null +++ b/dokogen/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Пакет генератора документов 152-ФЗ""" + +from .models import Company, CommissionMember, InformationSystem, PaperDocument +from .declension import inflect_name, decline, company_name_decline, get_short_fio, get_initials +from .variables import build_replacements, VARIABLE_DEFS +from .generator import process_template + +# Импорт из Excel +from . import importer + +__version__ = "1.0.0" +__app_name__ = "ДоКоГеНеРаТоР" diff --git a/dokogen/__main__.py b/dokogen/__main__.py new file mode 100644 index 0000000..0f12052 --- /dev/null +++ b/dokogen/__main__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Точка входа""" + +import sys +import os + +# Добавляем путь к пакету +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ui import main + +if __name__ == "__main__": + main() diff --git a/dokogen/declension.py b/dokogen/declension.py new file mode 100644 index 0000000..8e052b5 --- /dev/null +++ b/dokogen/declension.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Склонение (pymorphy3 + правила для ФИО/организаций)""" + +from functools import lru_cache +from typing import Dict +import re + +try: + import pymorphy3 + morph = pymorphy3.MorphAnalyzer() + MORPH_AVAILABLE = True +except ImportError: + morph = None + MORPH_AVAILABLE = False + +CASES = ["nomn", "gent", "datv", "accs", "ablt", "loct"] +CASE_MAP = { + "nominative": "nomn", "им": "nomn", + "genitive": "gent", "род": "gent", + "dative": "datv", "дат": "datv", + "accusative": "accs", "вин": "accs", + "instrumental": "ablt", "твор": "ablt", + "prepositional": "loct", "пр": "loct", +} +CASE_NAMES = { + "nomn": "Именительный (Кто? Что?)", + "gent": "Родительный (Кого? Чего?)", + "datv": "Дательный (Кому? Чему?)", + "accs": "Винительный (Кого? Что?)", + "ablt": "Творительный (Кем? Чем?)", + "loct": "Предложный (О ком? О чём?)", +} +CASE_SHORT = { + "nomn": "им.п.", "gent": "род.п.", "datv": "дат.п.", + "accs": "вин.п.", "ablt": "твор.п.", "loct": "пр.п.", +} + + +def _normalize_case(case: str) -> str: + return CASE_MAP.get(case, "nomn" if case not in CASES else case) + + +def inflect_name(name: str) -> Dict[str, str]: + """Склонение названия организации по всем падежам.""" + return {gr: company_name_decline(name, gr) for gr in CASES} + + +@lru_cache(maxsize=512) +def company_name_decline(name: str, case: str) -> str: + """Склонение названия организации.""" + if not name: + return "" + case_gr = _normalize_case(case) + if case_gr == "nomn": + return name + + m = re.match(r'^(.+?)\s*([\u00ab\"].*[\u00bb\"])$', name.strip()) + if m: + prefix = m.group(1).strip() + quoted = m.group(2) + if prefix: + return decline(prefix, case_gr, decline_all=True) + " " + quoted + return name + return decline(name, case_gr, decline_all=True) + + +@lru_cache(maxsize=512) +def decline(text: str, case: str, decline_all: bool = False) -> str: + """Склонение строки (ФИО, фразы) по падежам.""" + if not text: + return "" + case_gr = _normalize_case(case) + if case_gr == "nomn": + return text + + clean_text, quoted = _extract_quoted(text) + tokens = re.split(r"(\s+)", clean_text) + content_tokens = [t for t in tokens if not t.isspace() and t.strip()] + + only_first = False + if not decline_all and len(content_tokens) >= 2: + for ct in content_tokens[1:]: + if ct[0].islower(): + only_first = True + break + + result_parts = [] + content_idx = 0 + for token in tokens: + if token.isspace() or not token.strip(): + result_parts.append(token) + continue + stripped = token.strip() + if only_first and content_idx > 0: + result_parts.append(token) + content_idx += 1 + continue + if _is_quoted(stripped): + result_parts.append(token) + content_idx += 1 + continue + if stripped.isupper() and len(stripped) > 1: + result_parts.append(token) + content_idx += 1 + continue + inflected = _inflect_word(stripped, case_gr) + if stripped[0].isupper(): + inflected = inflected[0].upper() + inflected[1:] + result_parts.append(inflected) + content_idx += 1 + + return _restore_quoted("".join(result_parts), quoted) + + +def _inflect_word(word: str, case_gr: str) -> str: + """Склонение одного слова через pymorphy3 с fallback на ручные правила.""" + if not word or len(word) < 2 or case_gr == "nomn": + return word + if not MORPH_AVAILABLE or morph is None: + return _rules_decline(word, case_gr) or word + try: + parsed = morph.parse(word) + if not parsed: + return _rules_decline(word, case_gr) or word + best = parsed[0] + inflected = best.inflect({case_gr}) + if inflected: + return inflected.word + return _rules_decline(word, case_gr) or word + except Exception: + return _rules_decline(word, case_gr) or word + + +def _rules_decline(word: str, case_gr: str) -> str: + """Ручное склонение фамилий.""" + w = word.lower() + # Мужские фамилии на -ов/-ев/-ёв + if w.endswith(('ов', 'ев', 'ёв')) and not w.endswith(('ова', 'ева', 'ёва')): + return { + 'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а', + 'ablt': word + 'ым', 'loct': word + 'е' + }.get(case_gr, word) + # Мужские фамилии на -ин + if w.endswith('ин') and not w.endswith('ина'): + return { + 'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а', + 'ablt': word + 'ым', 'loct': word + 'е' + }.get(case_gr, word) + # Женские фамилии на -ова/-ева/-ёва + if w.endswith(('ова', 'ева', 'ёва')): + stem = word[:-1] + return { + 'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у', + 'ablt': stem + 'ой', 'loct': stem + 'ой' + }.get(case_gr, word) + # Женские фамилии на -ина + if w.endswith('ина'): + stem = word[:-1] + return { + 'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у', + 'ablt': stem + 'ой', 'loct': stem + 'ой' + }.get(case_gr, word) + return word + + +def _extract_quoted(text: str): + """Извлекает подстроки в кавычках, заменяет плейсхолдерами.""" + if not text: + return text, {} + pattern = re.compile(r'(\u00ab[^\u00bb]*\u00bb|"[^"]*")') + placeholders = {} + counter = [0] + + def _replace(m): + ph = f'__Q_{counter[0]}__' + placeholders[ph] = m.group(0) + counter[0] += 1 + return ph + + return pattern.sub(_replace, text), placeholders + + +def _restore_quoted(text: str, placeholders: dict) -> str: + for ph, original in placeholders.items(): + text = text.replace(ph, original) + return text + + +def _is_quoted(word: str) -> bool: + w = word.strip() + if not w: + return False + return (w.startswith("\u00ab") and w.endswith("\u00bb")) or \ + (w.startswith('"') and w.endswith('"')) + + +# ==================== ФОРМАТИРОВАНИЕ ФИО ==================== + +def get_short_fio(fio: str) -> str: + """Фамилия И.О.""" + if not fio: + return "" + parts = fio.split() + if len(parts) >= 2: + initials = parts[1][0] + "." + if len(parts) >= 3: + initials += parts[2][0] + "." + return parts[0] + " " + initials + return fio + + +def get_initials(fio: str) -> str: + """И.О. Фамилия""" + if not fio: + return "" + parts = fio.split() + if len(parts) >= 2: + initials = parts[1][0] + "." + if len(parts) >= 3: + initials += parts[2][0] + "." + return initials + " " + parts[0] + return fio diff --git a/dokogen/dictionaries/defense_tools.json b/dokogen/dictionaries/defense_tools.json new file mode 100644 index 0000000..5b4a952 --- /dev/null +++ b/dokogen/dictionaries/defense_tools.json @@ -0,0 +1,14 @@ +[ + "Антивирусное ПО", + "СКЗИ (криптография)", + "Межсетевой экран", + "СЗИ от НСД", + "СОВ (обнаружение вторжений)", + "Доверенная загрузка", + "SIEM-система", + "DLP-система", + "VPN", + "Система парольной защиты", + "Антиспам", + "Средства резервного копирования" +] diff --git a/dokogen/dictionaries/pd_actions.json b/dokogen/dictionaries/pd_actions.json new file mode 100644 index 0000000..b66179a --- /dev/null +++ b/dokogen/dictionaries/pd_actions.json @@ -0,0 +1,22 @@ +[ + "Сбор персональных данных", + "Запись персональных данных", + "Систематизация персональных данных", + "Накопление персональных данных", + "Хранение персональных данных", + "Уточнение (обновление, изменение) персональных данных", + "Извлечение персональных данных", + "Использование персональных данных", + "Передача (распространение, предоставление, доступ) персональных данных", + "Обезличивание персональных данных", + "Блокирование персональных данных", + "Удаление персональных данных", + "Уничтожение персональных данных", + "Трансграничная передача персональных данных", + "Автоматизированная обработка персональных данных", + "Неавтоматизированная обработка персональных данных", + "Смешанная обработка персональных данных", + "Формирование и ведение баз данных", + "Передача данных третьим лицам", + "Внутренняя передача данных между подразделениями" +] diff --git a/dokogen/dictionaries/pd_categories.json b/dokogen/dictionaries/pd_categories.json new file mode 100644 index 0000000..861bc5c --- /dev/null +++ b/dokogen/dictionaries/pd_categories.json @@ -0,0 +1,8 @@ +[ + "работники оператора", + "клиенты", + "контрагенты", + "родственники работников", + "соискатели", + "иные лица" +] diff --git a/dokogen/dictionaries/pd_items.json b/dokogen/dictionaries/pd_items.json new file mode 100644 index 0000000..be6fe4d --- /dev/null +++ b/dokogen/dictionaries/pd_items.json @@ -0,0 +1,28 @@ +[ + "Фамилия, имя, отчество", + "Дата рождения", + "Место рождения", + "Паспортные данные", + "Адрес регистрации", + "Адрес проживания", + "ИНН", + "СНИЛС", + "Номер телефона", + "Адрес электронной почты", + "Образование", + "Профессия", + "Сведения о доходах", + "Семейное положение", + "Состав семьи", + "Состояние здоровья", + "Национальность", + "Гражданство", + "Сведения о судимости", + "Фотография", + "Биометрические данные", + "Рабочий e-mail", + "Должность", + "Табельный номер", + "Сведения о воинском учете", + "Сведения о командировках" +] diff --git a/dokogen/generator.py b/dokogen/generator.py new file mode 100644 index 0000000..717a36f --- /dev/null +++ b/dokogen/generator.py @@ -0,0 +1,426 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Генератор документов из шаблонов DOCX""" + +import os +import re +import io +from datetime import datetime +from typing import Dict, List, Optional, Callable + +try: + from docx import Document + from docx.oxml.ns import qn + from docx.oxml import OxmlElement + from lxml import etree +except ImportError: + Document = None + +from .variables import OLD_VAR_MAP, OLD_VAR_PATTERN, VAR_PATTERN + +# Стандартный шаблон для незаполненных переменных +EMPTY_MARKER = '—' + + +def process_template( + template_path: str, + replacements: Dict[str, str], + output_path: Optional[str] = None, + log_func: Optional[Callable] = None, +) -> str: + """Обрабатывает шаблон DOCX: подстановка переменных, циклы, сохранение.""" + if Document is None: + raise ImportError("python-docx не установлен. Установите: pip install python-docx") + + if not os.path.exists(template_path): + raise FileNotFoundError(f"Шаблон не найден: {template_path}") + + if output_path is None: + base, ext = os.path.splitext(template_path) + output_path = f"{base}_result{ext}" + + doc = Document(template_path) + + # 1. Миграция старых переменных <...> → {{...}} + _migrate_old_vars(doc) + + # 2. Удаление proofErr + _strip_prooferr(doc) + + # 3. Обработка циклов (loop/loop_end) + loops = replacements.pop('_loops', {}) + if loops: + _process_loops(doc, loops) + + # 4. Перезагрузка после циклов + buf = io.BytesIO() + doc.save(buf) + buf.seek(0) + doc = Document(buf) + + # 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. Логирование незаменённых переменных + _log_unmatched(doc, replacements, log_func if log_func else lambda x: None) + + # 7. Подсветка незаполненных + _highlight_unmatched(doc) + + # 8. Сохранение + doc.save(output_path) + + if log_func: + log_func(f"✅ {os.path.basename(output_path)}") + return output_path + + +# ==================== МИГРАЦИЯ СТАРЫХ ПЕРЕМЕННЫХ ==================== + +def _migrate_old_vars(doc): + """Заменяет на {{new_var}} в документе.""" + def _migrate_text(text): + return OLD_VAR_PATTERN.sub(lambda m: OLD_VAR_MAP.get(m.group(0), m.group(0)), text) + + for para in doc.paragraphs: + new_text = _migrate_text(para.text) + if new_text != para.text: + if para.runs: + first = para.runs[0] + para.clear() + run = para.add_run(new_text) + _copy_run_style(first, run) + else: + para.clear() + para.add_run(new_text) + + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + new_text = _migrate_text(para.text) + if new_text != para.text: + if para.runs: + first = para.runs[0] + para.clear() + run = para.add_run(new_text) + _copy_run_style(first, run) + else: + para.clear() + para.add_run(new_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: + new_text = _migrate_text(para.text) + if new_text != para.text: + if para.runs: + first = para.runs[0] + para.clear() + run = para.add_run(new_text) + _copy_run_style(first, run) + else: + para.clear() + para.add_run(new_text) + + +# ==================== ЗАМЕНА В ЭЛЕМЕНТАХ ==================== + +def _replace_in_paragraphs(paragraphs, replacements): + for para in paragraphs: + full = para.text + # Пропускаем строки циклов + if 'loop:' in full or 'loop_end' in full: + continue + matches = VAR_PATTERN.findall(full) + if not matches: + continue + new_text = full + for var in matches: + val = replacements.get(var) + if val is not None: + new_text = new_text.replace(var, str(val)) + if new_text != full: + if para.runs: + first = para.runs[0] + para.clear() + run = para.add_run(new_text) + _copy_run_style(first, run) + else: + para.clear() + para.add_run(new_text) + + +def _replace_in_tables(tables, replacements): + for table in tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + full = para.text + matches = VAR_PATTERN.findall(full) + if not matches: + continue + new_text = full + for var in matches: + val = replacements.get(var) + if val is not None: + new_text = new_text.replace(var, str(val)) + if new_text != full: + if para.runs: + first = para.runs[0] + para.clear() + run = para.add_run(new_text) + _copy_run_style(first, run) + else: + para.clear() + para.add_run(new_text) + + +def _replace_in_headers_footers(doc, replacements): + for section in doc.sections: + for hf in [section.header, section.footer, + section.first_page_header, section.first_page_footer]: + if hf: + _replace_in_paragraphs(hf.paragraphs, replacements) + + +def _copy_run_style(source, target): + """Копирует форматирование из одного run в другой.""" + try: + target.bold = source.bold + target.italic = source.italic + target.underline = source.underline + if source.font: + target.font.size = source.font.size + target.font.name = source.font.name + except Exception: + pass + + +def _strip_prooferr(doc): + ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' + for elem in doc.element.iter(): + for child in list(elem): + if child.tag == f'{{{ns}}}proofErr': + elem.remove(child) + + +# ==================== ОБРАБОТКА ЦИКЛОВ ==================== + +LOOP_START = re.compile(r'(?:<!--|))?') +LOOP_END = re.compile(r'(?:<!--|))?') + + +def _process_loops(doc, loops: Dict[str, List[Dict]]): + body = doc.element.body + max_passes = 20 + for _ in range(max_passes): + body_str = etree.tostring(body, encoding='unicode') + # Находим все ключи циклов + found = set(LOOP_START.findall(body_str)) + if not found: + break + changed = False + for key in sorted(found): + items = loops.get(key, []) + if not items: + _remove_loop_block(body, key) + changed = True + else: + _expand_loop(body, key, items) + changed = True + body = doc.element.body + if not changed: + break + + +def _expand_loop(body, key: str, items: List[Dict]): + body_str = etree.tostring(body, encoding='unicode') + + markers = [] + for m in LOOP_START.finditer(body_str): + if m.group(1) == key: + markers.append((m.start(), True, m.end())) + for m in LOOP_END.finditer(body_str): + markers.append((m.start(), False, m.end())) + + if len(markers) < 2: + return + markers.sort() + + start_pos = end_pos = start_end = end_end = None + for pos, is_start, end in markers: + if is_start and start_pos is None: + start_pos = pos + start_end = end + elif not is_start and start_pos is not None: + end_pos = pos + end_end = end + break + if start_pos is None or end_pos is None: + return + + # Определяем структурный элемент (параграф или строка таблицы) + s_start, s_end = _find_element_bounds(body_str, start_pos) + e_start, e_end = _find_element_bounds(body_str, end_pos) + + if s_start is None or e_end is None: + return + + # Извлекаем шаблон (содержимое между маркерами) + template_xml = body_str[start_end:end_pos] + # Убираем остатки маркеров + template_xml = LOOP_START.sub('', template_xml) + template_xml = LOOP_END.sub('', template_xml) + + # Размножаем + expanded = [] + for idx, item in enumerate(items, 1): + clone = template_xml + for field, value in item.items(): + clone = clone.replace(f'{{{{item.{field}}}}}', str(value) if value else EMPTY_MARKER) + clone = clone.replace(f'{{{{{field}}}}}', str(value) if value else EMPTY_MARKER) + clone = clone.replace('{{item.number}}', str(idx)) + clone = clone.replace('{{number}}', str(idx)) + clone = clone.replace('\u27f5BR\u27f5', '') + expanded.append(clone) + + new_str = body_str[:s_start] + ''.join(expanded) + body_str[e_end:] + try: + new_body = etree.fromstring(new_str.encode('utf-8')) + parent = body.getparent() + if parent is not None: + parent.replace(body, new_body) + except Exception: + pass + + +def _remove_loop_block(body, key: str): + body_str = etree.tostring(body, encoding='unicode') + start_pos = None + for m in LOOP_START.finditer(body_str): + if m.group(1) == key: + start_pos = m.start() + break + if start_pos is None: + return + s_start, s_end = _find_element_bounds(body_str, start_pos) + end_match = LOOP_END.search(body_str, start_pos) + if end_match: + _, e_end = _find_element_bounds(body_str, end_match.start()) + new_str = body_str[:s_start] + body_str[e_end:] + else: + new_str = body_str[:s_start] + body_str[s_end:] + try: + new_body = etree.fromstring(new_str.encode('utf-8')) + parent = body.getparent() + if parent is not None: + parent.replace(body, new_body) + except Exception: + pass + + +def _find_element_bounds(xml_str: str, pos: int): + """Находит границы элемента (table row или paragraph), содержащего позицию.""" + # Пробуем tr + tr_open = max(xml_str.rfind('', 0, pos)) + if tr_open != -1: + tr_close = xml_str.find('', pos) + if tr_close != -1: + return tr_open, tr_close + len('') + # Пробуем p + p_open = max(xml_str.rfind('', 0, pos)) + if p_open != -1: + p_close = xml_str.find('', pos) + if p_close != -1: + return p_open, p_close + len('') + 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: + rpr = OxmlElement('w:rPr') + run._element.insert(0, rpr) + color = OxmlElement('w:color') + color.set(f'{{{ns}}}val', 'FF0000') + rpr.append(color) + sz = OxmlElement('w:sz') + sz.set(f'{{{ns}}}val', '22') + rpr.append(sz) + + for para in doc.paragraphs: + for run in para.runs: + if _has_unmatched(run.text): + _highlight_run(run) + + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + for run in para.runs: + if _has_unmatched(run.text): + _highlight_run(run) + + for section in doc.sections: + for hf in [section.header, section.footer]: + if hf: + for para in hf.paragraphs: + for run in para.runs: + if _has_unmatched(run.text): + _highlight_run(run) diff --git a/dokogen/importer.py b/dokogen/importer.py new file mode 100644 index 0000000..39fb109 --- /dev/null +++ b/dokogen/importer.py @@ -0,0 +1,430 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Модуль импорта из Excel (опросный лист 152-ФЗ)""" + +import re +import traceback +from datetime import datetime, timedelta +from typing import Callable, Optional + +try: + import openpyxl +except ImportError: + openpyxl = None + + +def clean_value(val): + """Очистка и нормализация значения ячейки Excel.""" + if val is None: + return '' + if isinstance(val, (int, float)): + # Попытка распознать дату (серийный номер Excel) + if 10000 < val < 80000: + serial = int(val) + if serial > 60: + serial -= 1 + dt = datetime(1899, 12, 30) + timedelta(days=serial) + return dt.strftime('%d.%m.%Y') + if isinstance(val, float) and val == int(val): + return str(int(val)) + return str(val) + text = str(val).strip() + text = text.replace('\n', ' ').replace('\r', ' ') + text = ' '.join(text.split()) + # Приведение регистра: если текст содержит только заглавные буквы РУ + if len(text) > 4 and any('А' <= c <= 'Я' or c == 'Ё' for c in text): + has_lower = any('а' <= c <= 'я' or c == 'ё' for c in text) + if not has_lower: + words = text.split() + if len(words) >= 3: + text = text.title() + else: + text = text.capitalize() + return text + + +def import_152fz(filepath: str, log_fn: Optional[Callable] = None) -> dict: + """Импорт данных из опросного листа Excel (152-ФЗ).""" + if openpyxl is None: + raise ImportError("openpyxl не установлен. Установите: pip install openpyxl") + + if log_fn is None: + log_fn = lambda msg: None + + log_fn(f"Загрузка опросного листа: {filepath}") + + data = { + 'fullName': '', 'shortName': '', 'addressLegal': '', 'addressActual': '', + 'email': '', 'phone': '', 'inn': '', 'ogrn': '', 'kpp': '', + 'okved': '', 'chiefPosition': '', 'chiefFio': '', + 'ispdnFio': '', 'ispdnPosition': '', 'paperDocuments': '', 'paperStorage': '', + 'ispdnDepartment': '', 'ispdnEmail': '', 'ispdnPhone': '', + 'informationSystems': [], 'commission': [], 'employeesAccess': [], + } + + try: + wb = openpyxl.load_workbook(filepath, data_only=True, read_only=False) + except Exception as e: + log_fn(f"❌ Ошибка открытия файла: {e}") + return data + + try: + _import_152_common(wb, data, log_fn) + _import_152_is_list(wb, data, log_fn) + _import_152_employees(wb, data, log_fn) + except Exception as e: + log_fn(f"❌ Ошибка при импорте: {e}") + log_fn(traceback.format_exc()) + finally: + wb.close() + + # Копирование полей ИСПДн в админа, если админ не заполнен + for src, dst in [('ispdnFio', 'administratorFio'), ('ispdnFio', 'adminFio'), + ('ispdnPosition', 'administratorPosition'), ('ispdnPosition', 'adminPosition'), + ('ispdnPhone', 'phone'), ('ispdnEmail', 'email')]: + if data.get(src) and not data.get(dst): + data[dst] = data[src] + + _validate_company_data(data, log_fn) + return data + + +def _import_152_common(wb, data, log_fn): + """Импорт общих сведений из листа 'Общие сведения'.""" + sheet_name = None + for sn in wb.sheetnames: + if 'общие' in sn.lower(): + sheet_name = sn + break + if not sheet_name: + log_fn("⚠ Лист 'Общие сведения' не найден") + return + + ws = wb[sheet_name] + field_map = [ + ('полное наименование организации', 'fullName'), + ('сокращенное наименование организации', 'shortName'), + ('адрес организации \\(юридический\\)', 'addressLegal'), + ('адрес организации \\(фактический\\)', 'addressActual'), + ('инн', 'inn'), + ('дата.*огрн', 'ogrnDate'), + ('огрн', 'ogrn'), + ('кпп', 'kpp'), + ('основной оквэд', 'okved'), + ('должность руководителя', 'chiefPosition'), + ('фио руководителя', 'chiefFio'), + ('должность.*защит.*информ|должность.*администрат', 'ispdnPosition'), + ('фио.*защит.*информ|фио.*администрат', 'ispdnFio'), + ('электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист', 'ispdnEmail'), + ('телефон.*защит|телефон.*ответств|телефон.*специалист', 'ispdnPhone'), + ('структур.*подраздел', 'ispdnDepartment'), + ('номер.*договора|номер.*контракта', 'contractNumber'), + ('дата.*договора|дата.*контракта', 'contractDate'), + ('названи.*документ', 'paperDocuments'), + ('место.*хранен', 'paperStorage'), + ('должность.*ответственн', 'responsiblePosition'), + ('фио.*ответственн', 'responsibleFio'), + ('должность.*администрат.*(?:безопас|защит|пд)', 'administratorPosition'), + ('фио.*администрат.*(?:безопас|защит|пд)', 'administratorFio'), + ('сайт организации', 'site_name'), + ('должность ответственного за сайт|должность.*отв.*сайт', 'site_responsible_position'), + ('фио.*отв.*сайт|фио.*сайт', 'site_responsible_fio'), + ('наименование организации.*разрабат.*сайт|наименование.*размещала.*сайт|разрабат.*сайт.*организац', 'site_service_provider'), + ('инн.*разрабат.*сайт|инн.*размещала.*сайт', 'site_service_provider_inn'), + ('размещение сайта|адрес расположения серверов|адрес.*сервер.*сайт', 'site_hosting'), + ('адрес.*хостинг|адрес.*располож.*сервер', 'site_hosting_address'), + ('электронная почта', 'email'), + ('телефон', 'phone'), + ] + + for row in ws.iter_rows(min_row=1, values_only=True): + field_raw = clean_value(row[0]) if row[0] is not None else '' + field = field_raw.lower() + value = clean_value(row[1]) if len(row) > 1 else '' + if not field: + continue + for keyword, key in field_map: + if re.search(keyword, field): + if value: + data[key] = value + log_fn(f" {keyword}: {value}") + break + + +def _import_152_is_list(wb, data, log_fn): + """Импорт информационных систем из остальных листов.""" + is_sheets = [sn for sn in wb.sheetnames if sn not in ('Общие сведения', 'Сотрудники')] + if not is_sheets: + log_fn("⚠ Листы ИС не найдены") + return + + is_list = [] + for sheet_name in is_sheets: + ws = wb[sheet_name] + rows = [] + for row in ws.iter_rows(min_row=1, values_only=True): + vals = [clean_value(c) for c in row] + field = (vals[0] or '').strip() if vals else '' + if not field: + continue + rows.append(vals) + + if len(rows) < 2: + continue + + is_obj = { + 'name': '', 'description': '', 'room': '', 'employee': '', 'position': '', + 'personalDataList': [], 'pd_subjects_list': [], 'pd_actions': [], + 'usersList': [], 'isInternet': False, 'personalDataCount': 'менее 100 000', + 'defense_tools': [], 'processing_mode': '', + } + + current_section = 'header' + pd_items_raw = [] + subjects_raw = [] + actions_raw = [] + + for vals in rows: + field = (vals[0] or '').strip() + field_lower = field.lower() + val_b = clean_value(vals[1]) if len(vals) > 1 else '' + is_checked = val_b.upper() in ('ДА', 'YES', '✓', '✔', '1', 'TRUE', 'ЕСТЬ') + + if 'цель обработки пд' in field_lower or ('цель обработки' in field_lower and ':' in field): + current_section = 'purpose' + continue + if 'перечисленные пдн принадлежат' in field_lower or 'принадлеж' in field_lower: + current_section = 'subjects' + continue + if 'количество записей' in field_lower or 'количество субъект' in field_lower: + if val_b: + is_obj['personalDataCount'] = val_b + continue + if 'перечень действий с пд' in field_lower or ('действи' in field_lower and 'пд' in field_lower): + current_section = 'actions' + continue + if 'способ обработки пд' in field_lower: + if val_b: + is_obj['processing_mode'] = val_b + continue + if 'интернет' in field_lower: + is_obj['isInternet'] = is_checked + continue + + if current_section == 'header': + if 'перечень пд' in field_lower: + current_section = 'pd_items' + elif 'название ис' in field_lower or 'наименование ис' in field_lower or 'наименование информационной' in field_lower: + is_obj['name'] = val_b + log_fn(f" ИС: {val_b}") + elif 'сотрудник' in field_lower and 'должност' not in field_lower: + is_obj['employee'] = val_b + if val_b: + parts = [p.strip() for p in val_b.replace('\n', ';').split(';') if p.strip()] + for part in parts: + cleaned = part.strip() + if ':' in cleaned and len(cleaned.split(':')[0]) < 20: + cleaned = cleaned.split(':', 1)[1].strip() + if cleaned and cleaned not in is_obj['usersList']: + is_obj['usersList'].append(cleaned) + elif 'должность сотрудника' in field_lower: + is_obj['position'] = val_b + elif 'наименование отдела' in field_lower: + pass + elif 'номер кабинета' in field_lower: + is_obj['room'] = val_b + elif 'значение' in field_lower or 'примечание' in field_lower: + continue + elif val_b and 'выбрать' not in field_lower: + if not is_obj['description']: + is_obj['description'] = f"{field}: {val_b}" + else: + is_obj['description'] += f"\n{field}: {val_b}" + elif current_section == 'pd_items': + if is_checked and 'выбрать' not in field_lower and field_lower != 'значение': + pd_items_raw.append(field) + if 'цель обработки' in field_lower: + current_section = 'purpose' + elif current_section == 'purpose': + purpose_val = field or val_b or '' + if purpose_val and 'выбрать' not in purpose_val.lower(): + is_obj['purpose'] = purpose_val + log_fn(f" Цель: {purpose_val[:80]}") + current_section = 'header' + elif current_section == 'subjects': + if is_checked and 'выбрать' not in field_lower: + subjects_raw.append(field) + if 'количество записей' in field_lower or 'действи' in field_lower: + continue + elif current_section == 'actions': + if is_checked and 'выбрать' not in field_lower and 'защита' not in field_lower[:20]: + actions_raw.append(field) + if 'защита' in field_lower and 'действи' not in field_lower: + current_section = 'defense' + elif current_section == 'defense': + defense_keywords = { + 'антивирус': 'Антивирус', + 'крипто': 'СКЗИ', + 'межсетев': 'Межсетевой экран', + 'сзи.*от несанкционирован': 'СЗИ от НСД', + 'обнаружен.*вторжен': 'СОВ', + 'программные.*средств.*модул': 'Доверенная загрузка', + 'доверен.*загрузк': 'Доверенная загрузка', + 'гарантирован': 'Доверенная загрузка', + } + matched_label = None + for kw, label in defense_keywords.items(): + if re.search(kw, field_lower): + matched_label = label + break + if matched_label and val_b and 'выбрать' not in field_lower: + is_obj['defense_tools'].append(f"{matched_label}: {val_b}") + elif matched_label: + pass + elif 'куда' in field_lower or 'передаются' in field_lower: + current_section = 'header' + elif 'способ обработки' in field_lower or 'локальную сеть' in field_lower or 'интернет' in field_lower: + current_section = 'header' + + if pd_items_raw: + is_obj['personalDataList'] = pd_items_raw + log_fn(f" ПДн: {len(pd_items_raw)}") + if subjects_raw: + is_obj['pd_subjects_list'] = subjects_raw + log_fn(f" Субъектов: {len(subjects_raw)}") + if actions_raw: + is_obj['pd_actions'] = actions_raw + log_fn(f" Действий: {len(actions_raw)}") + + if not is_obj['name']: + is_obj['name'] = sheet_name.strip() + log_fn(f" ⚠ ИС без названия — использовано имя листа: {is_obj['name']}") + + # Автоопределение категорий ПДн + pd_cats = [] + all_pd_text = ' '.join(p.lower() for p in pd_items_raw) + special_kw = ['состоян.*здоров', 'национальн', 'политическ', 'религиозн', 'философ', 'судимост', 'интимн'] + if any(re.search(kw, all_pd_text) for kw in special_kw): + pd_cats.append('специальные') + bio_kw = ['биометрическ', 'изображен.*лиц', 'голос.*человек', 'папилляр', 'дактилоскоп', 'фото.*изображен'] + if any(re.search(kw, all_pd_text) for kw in bio_kw): + pd_cats.append('биометрические') + if pd_items_raw: + pd_cats.append('иные') + if pd_cats: + is_obj['personalDataCategory'] = pd_cats + log_fn(f" Категории ПДн: {', '.join(pd_cats)}") + + is_list.append(is_obj) + log_fn(f" ✅ {is_obj['name']} — импортирована") + + if is_list: + data['informationSystems'] = is_list + log_fn(f"\n✅ Всего импортировано ИС: {len(is_list)}") + + +def _import_152_employees(wb, data, log_fn): + """Импорт комиссии и сотрудников из листа 'Сотрудники'.""" + sheet_name = None + for sn in wb.sheetnames: + if 'сотруд' in sn.lower(): + sheet_name = sn + break + if not sheet_name: + log_fn("⚠ Лист «Сотрудники» не найден") + return + + ws = wb[sheet_name] + rows = [] + for row in ws.iter_rows(min_row=1, values_only=True): + vals = [clean_value(c) for c in row] + if not vals or not vals[0]: + continue + rows.append(vals) + + if not rows: + log_fn("⚠ Лист «Сотрудники» пуст") + return + + commission = [] + in_commission = False + COMMISSION_ROLES = { + 'председатель': 'Председатель комиссии', + 'секретарь': 'Секретарь комиссии', + 'заместитель председателя': 'Заместитель председателя комиссии', + } + employees = [] + in_employees = False + + for vals in rows: + col_a = (vals[0] or '').strip() + col_b = clean_value(vals[1]) if len(vals) > 1 else '' + col_c = clean_value(vals[2]) if len(vals) > 2 else '' + col_d = clean_value(vals[3]) if len(vals) > 3 else '' + col_e = clean_value(vals[4]) if len(vals) > 4 else '' + a_lower = col_a.lower() + + if 'состав комиссии' in a_lower: + in_commission = True + in_employees = False + log_fn(" Секция: состав комиссии") + continue + if 'перечень сотрудников' in a_lower: + in_commission = False + in_employees = True + log_fn(" Секция: сотрудники с доступом к ПДн") + continue + if a_lower in ('№ п/п', '№', 'номер', ''): + continue + + if in_commission: + role = 'Член комиссии' + if col_e: + e_lower = col_e.lower() + for keyword, role_name in COMMISSION_ROLES.items(): + if keyword in e_lower: + role = role_name + break + if not col_b and not col_c: + continue + commission.append({ + 'role': role, + 'position': col_b, + 'fio': col_c.replace('\n', ' '), + }) + + if in_employees: + fio = col_c if col_c else col_d + if not fio: + continue + employee = { + 'position': col_b, + 'fio': fio.replace('\n', ' '), + } + if col_e: + employee['is_list'] = [s.strip() for s in col_e.split('\n') if s.strip()] + employees.append(employee) + + if commission: + commission = [c for c in commission if c['position'] or c['fio']] + data['commission'] = commission + log_fn(f" Комиссия: {len(commission)} чел.") + for m in commission[:3]: + log_fn(f" {m['role']}: {m['position'][:40]} — {m['fio'][:40]}") + + if employees: + data['employeesAccess'] = employees + log_fn(f" Сотрудников с доступом: {len(employees)}") + + +def _validate_company_data(data, log_fn): + """Проверка обязательных полей.""" + required = ['fullName', 'inn'] + missing = [f for f in required if not data.get(f)] + if missing: + log_fn(f"⚠️ ОБЯЗАТЕЛЬНЫЕ ПОЛЯ НЕ ЗАПОЛНЕНЫ: {', '.join(missing)}") + else: + log_fn("✅ Все обязательные поля заполнены") + + inn = (data.get('inn') or '').replace(' ', '') + if inn and (not inn.isdigit() or len(inn) not in (10, 12)): + log_fn(f"⚠️ ИНН подозрительного формата: «{inn}»") diff --git a/dokogen/models.py b/dokogen/models.py new file mode 100644 index 0000000..8028c36 --- /dev/null +++ b/dokogen/models.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Модели данных (только 152-ФЗ, только нужные поля)""" + +from dataclasses import dataclass, field, asdict +from typing import List, Dict, Any + + +@dataclass +class CommissionMember: + """Член комиссии по ПДн""" + role: str = "член комиссии" + position: str = "" + fio: str = "" + + +@dataclass +class InformationSystem: + """Информационная система (обработка ПДн)""" + name: str = "" + description: str = "" + is_local_network: bool = False + is_internet: bool = False + defence_level: str = "" + threat_type: str = "3" + category: str = "" + pd_list: str = "" + pd_count: str = "менее 100 000" + users: str = "" + defense_tools_list: List[str] = field(default_factory=list) + processing_modes: str = "" + subject_type: str = "работники организации" + pd_actions_list: List[str] = field(default_factory=list) + pd_subjects_list: List[str] = field(default_factory=list) + personal_data_list: List[str] = field(default_factory=list) + users_list: List[str] = field(default_factory=list) + personal_data_category: List[str] = field(default_factory=list) + purpose: str = "" + room: str = "" + + +@dataclass +class PaperDocument: + """Бумажный документ с ПДн""" + name: str = "" + storage: str = "" + + +@dataclass +class Company: + """Организация — основной объект (152-ФЗ)""" + # Полное название + full_name: str = "" + company_name_1: str = "" # именительный + company_name_2: str = "" # родительный + company_name_3: str = "" # дательный + company_name_4: str = "" # винительный + company_name_5: str = "" # творительный + company_name_6: str = "" # предложный + + # Краткое название + short_name: str = "" + short_name_1: str = "" + short_name_2: str = "" + short_name_3: str = "" + short_name_4: str = "" + short_name_5: str = "" + short_name_6: str = "" + + # Адрес и реквизиты + address: str = "" + city_name: str = "" + inn: str = "" + ogrn: str = "" + ogrn_date: str = "" + kpp: str = "" + + # Должностные лица + chief_position: str = "" + chief_fio: str = "" + responsible_position: str = "" + responsible_fio: str = "" + admin_position: str = "" + admin_fio: str = "" + + # Документ + contract_number: str = "" + contract_date: str = "" + + # Списки + commission: List[CommissionMember] = field(default_factory=list) + information_systems: List[InformationSystem] = field(default_factory=list) + paper_documents_list: List[PaperDocument] = field(default_factory=list) + employees_access: List[Dict[str, str]] = field(default_factory=list) + + def update_declensions(self, inflect_func): + """Обновить склонения полного названия""" + if self.full_name: + inflected = inflect_func(self.full_name) + self.company_name_1 = self.full_name + self.company_name_2 = inflected.get("gent", "") + self.company_name_3 = inflected.get("datv", "") + self.company_name_4 = inflected.get("accs", "") + self.company_name_5 = inflected.get("ablt", "") + self.company_name_6 = inflected.get("loct", "") + + def update_short_declensions(self, inflect_func): + """Обновить склонения сокращённого названия""" + if self.short_name: + inflected = inflect_func(self.short_name) + self.short_name_1 = self.short_name + self.short_name_2 = inflected.get("gent", "") + self.short_name_3 = inflected.get("datv", "") + self.short_name_4 = inflected.get("accs", "") + self.short_name_5 = inflected.get("ablt", "") + self.short_name_6 = inflected.get("loct", "") + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Company": + d = dict(data) + if "commission" in d and isinstance(d["commission"], list): + d["commission"] = [ + CommissionMember(**c) if isinstance(c, dict) else c + for c in d["commission"] + ] + if "information_systems" in d and isinstance(d["information_systems"], list): + d["information_systems"] = [ + InformationSystem(**isys) if isinstance(isys, dict) else isys + for isys in d["information_systems"] + ] + if "paper_documents_list" in d and isinstance(d["paper_documents_list"], list): + d["paper_documents_list"] = [ + PaperDocument(**p) if isinstance(p, dict) else p + for p in d["paper_documents_list"] + ] + if "employees_access" in d and not isinstance(d["employees_access"], list): + d["employees_access"] = [] + valid = {f.name for f in cls.__dataclass_fields__.values()} + return cls(**{k: v for k, v in d.items() if k in valid}) + + def get_folder_name(self) -> str: + if self.short_name: + return self.short_name + if self.full_name: + return self.full_name[:30] + return "Новая_организация" diff --git a/dokogen/ui.py b/dokogen/ui.py new file mode 100644 index 0000000..961aa45 --- /dev/null +++ b/dokogen/ui.py @@ -0,0 +1,1570 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Пользовательский интерфейс (tkinter)""" + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog, simpledialog, scrolledtext +import os +import json +import threading +from datetime import datetime +from typing import List, Optional + +from . import ( + Company, CommissionMember, InformationSystem, PaperDocument, + inflect_name, + build_replacements, VARIABLE_DEFS, + process_template, + get_short_fio, get_initials, +) + + +class DokoGenApp: + """Главное окно приложения.""" + + def __init__(self, root: tk.Tk): + self.root = root + root.title("ДоКоГеНеРаТоР — 152-ФЗ (Персональные данные)") + root.geometry("1200x800") + root.minsize(900, 600) + + self.model = Company() + self.current_file: Optional[str] = None + self._current_is_idx: Optional[int] = None + self._template_files: List[str] = [] + self._selected_templates: set = set() + self._last_template_dir: str = "" + self._autosave_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '.autosave', 'autosave_152.json' + ) + + # Переменные + self.doc_number_var = tk.StringVar(value="") + self.doc_date_var = tk.StringVar(value="") + self.templates_var = tk.StringVar() + self.save_var = tk.StringVar() + self.status_var = tk.StringVar(value="Готов") + + # Словари для хранения виджетов + self._company_entries: dict = {} + self._official_entries: dict = {} + self.commission_entries: list = [] + + self._build_menu() + self._build_ui() + self._enable_context_menu() + self._init_company() + + # Привязка закрытия + root.protocol("WM_DELETE_WINDOW", self._on_close) + + # Автосохранение + self._load_autosave() + self._start_autosave_timer() + + # ==================== МЕНЮ ==================== + def _build_menu(self): + menubar = tk.Menu(self.root) + self.root.config(menu=menubar) + + file_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Файл", menu=file_menu) + file_menu.add_command(label="Новое", command=self.new_data, accelerator="Ctrl+N") + file_menu.add_command(label="Открыть", command=self.open_data, accelerator="Ctrl+O") + file_menu.add_command(label="Сохранить", command=self.save_data, accelerator="Ctrl+S") + file_menu.add_command(label="Сохранить как", command=self.save_as_data, accelerator="Ctrl+Shift+S") + file_menu.add_separator() + + self.recent_menu = tk.Menu(file_menu, tearoff=0) + file_menu.add_cascade(label="Последние файлы", menu=self.recent_menu) + file_menu.add_separator() + file_menu.add_command(label="Выход", command=self._on_close) + + gen_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Генерация", menu=gen_menu) + gen_menu.add_command(label="Импорт из опросника", command=self.import_from_survey) + gen_menu.add_separator() + gen_menu.add_command(label="Сгенерировать всё", command=self.generate_all) + + help_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Справка", menu=help_menu) + help_menu.add_command(label="О программе", command=self.show_about) + help_menu.add_command(label="Переменные шаблонов", command=self.show_variables) + + # ==================== ИНТЕРФЕЙС ==================== + def _build_ui(self): + # Панель инструментов + toolbar = ttk.Frame(self.root) + toolbar.pack(side=tk.TOP, fill=tk.X, padx=5, pady=3) + + ttk.Button(toolbar, text="📂 Открыть", command=self.open_data).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="💾 Сохранить", command=self.save_data).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="📥 Импорт", command=self.import_from_survey).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="📁 Шаблоны", command=self.select_templates).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="⚡ Генерация", command=self.generate_all).pack(side=tk.LEFT, padx=2) + + # Ноутбук (вкладки) + self.notebook = ttk.Notebook(self.root) + self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=2) + + self._build_company_tab() + self._build_is_tab() + self._build_commission_tab() + self._build_users_tab() + self._build_generation_tab() + self._build_log_tab() + + # Статус-бар + statusbar = ttk.Label(self.root, textvariable=self.status_var, + relief=tk.SUNKEN, anchor=tk.W) + statusbar.pack(side=tk.BOTTOM, fill=tk.X) + + # Горячие клавиши + self.root.bind("", lambda e: self.new_data()) + self.root.bind("", lambda e: self.open_data()) + self.root.bind("", lambda e: self.save_data()) + self.root.bind("", lambda e: self.save_as_data()) + + # ---------- ВКЛАДКА "ОРГАНИЗАЦИЯ" ---------- + def _build_company_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Организация") + + 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) + + main = ttk.Frame(scrollable, padding=15) + main.pack(fill=tk.BOTH, expand=True) + + # --- Название организации --- + f1 = ttk.LabelFrame(main, text="Наименование организации", padding=10) + f1.pack(fill=tk.X, pady=5) + + ttk.Label(f1, text="Полное название:").grid(row=0, column=0, sticky=tk.W, pady=2) + self.company_name_entry = ttk.Entry(f1, width=60) + self.company_name_entry.grid(row=0, column=1, padx=10, sticky=tk.EW) + ttk.Button(f1, text="Склонять", command=self._update_company_declensions).grid( + row=0, column=2, padx=5) + + ttk.Label(f1, text="Краткое название:").grid(row=1, column=0, sticky=tk.W, pady=2) + self.short_name_entry = ttk.Entry(f1, width=60) + self.short_name_entry.grid(row=1, column=1, padx=10, sticky=tk.EW) + ttk.Button(f1, text="Склонять", command=self._update_short_declensions).grid( + row=1, column=2, padx=5) + f1.columnconfigure(1, weight=1) + + # Склонения полного названия + decl_frame = ttk.Frame(f1) + decl_frame.grid(row=2, column=0, columnspan=3, sticky=tk.EW, pady=(5, 0)) + f1.columnconfigure(0, weight=0) + f1.columnconfigure(1, weight=1) + f1.columnconfigure(2, weight=0) + + self.decl_entries = {} + cases = [ + ("Именительный (Кто? Что?)", "nomn"), + ("Родительный (Кого? Чего?)", "gent"), + ("Дательный (Кому? Чему?)", "datv"), + ("Винительный (Кого? Что?)", "accs"), + ("Творительный (Кем? Чем?)", "ablt"), + ("Предложный (О ком? О чём?)", "loct"), + ] + for i, (label, code) in enumerate(cases): + ttk.Label(decl_frame, text=label, font=('', 8)).grid(row=i, column=0, sticky=tk.W, padx=(0, 5)) + entry = ttk.Entry(decl_frame, width=55, font=('', 8)) + entry.grid(row=i, column=1, sticky=tk.EW, pady=1) + self.decl_entries[code] = entry + decl_frame.columnconfigure(1, weight=1) + + # Склонения краткого названия + ttk.Label(decl_frame, text="", font=('', 5)).grid(row=6, column=0) + self.short_decl_entries = {} + for i, (label, code) in enumerate(cases): + row = i + 7 + ttk.Label(decl_frame, text=f"Кратк. {label.lower()}", + font=('', 8)).grid(row=row, column=0, sticky=tk.W, padx=(0, 5)) + entry = ttk.Entry(decl_frame, width=55, font=('', 8)) + entry.grid(row=row, column=1, sticky=tk.EW, pady=1) + self.short_decl_entries[code] = entry + decl_frame.columnconfigure(1, weight=1) + + # --- Адрес и реквизиты --- + f2 = ttk.LabelFrame(main, text="Адрес и реквизиты", padding=10) + f2.pack(fill=tk.X, pady=5) + + addr_fields = [ + ("Юридический адрес:", "address"), + ("Город:", "city_name"), + ("ИНН:", "inn"), + ("КПП:", "kpp"), + ("ОГРН:", "ogrn"), + ("Дата ОГРН:", "ogrn_date"), + ] + for i, (label, key) in enumerate(addr_fields): + ttk.Label(f2, text=label).grid(row=i, column=0, sticky=tk.W, pady=2) + 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) + + # --- Должностные лица --- + f3 = ttk.LabelFrame(main, text="Должностные лица", padding=10) + f3.pack(fill=tk.X, pady=5) + + of_fields = [ + ("Руководитель (должность):", "chief_position"), + ("Руководитель (ФИО):", "chief_fio"), + ("Ответственный (должность):", "responsible_position"), + ("Ответственный (ФИО):", "responsible_fio"), + ("Администратор (должность):", "admin_position"), + ("Администратор (ФИО):", "admin_fio"), + ] + for i, (label, key) in enumerate(of_fields): + ttk.Label(f3, text=label).grid(row=i, column=0, sticky=tk.W, pady=2) + entry = ttk.Entry(f3, width=50) + entry.grid(row=i, column=1, padx=10, sticky=tk.EW, pady=1) + self._official_entries[key] = entry + f3.columnconfigure(1, weight=1) + + # --- Документ --- + f4 = ttk.LabelFrame(main, text="Документ", padding=10) + f4.pack(fill=tk.X, pady=5) + ttk.Label(f4, text="Номер договора:").grid(row=0, column=0, sticky=tk.W) + ttk.Entry(f4, textvariable=self.doc_number_var, width=25).grid( + row=0, column=1, padx=10, sticky=tk.W) + ttk.Label(f4, text="Дата договора:").grid(row=1, column=0, sticky=tk.W) + ttk.Entry(f4, textvariable=self.doc_date_var, width=25).grid( + row=1, column=1, padx=10, sticky=tk.W) + + # ---------- ВКЛАДКА "ИНФОРМАЦИОННЫЕ СИСТЕМЫ" ---------- + def _build_is_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Информационные системы") + + # Левая панель — список ИС + left = ttk.Frame(tab) + left.pack(side=tk.LEFT, fill=tk.Y, padx=5, pady=5) + ttk.Label(left, text="Информационные системы:").pack() + self.is_listbox = tk.Listbox(left, width=35, height=18, exportselection=False) + self.is_listbox.pack(fill=tk.BOTH, expand=True, pady=5) + self.is_listbox.bind('<>', self._on_is_select) + + btn_frame = ttk.Frame(left) + btn_frame.pack(fill=tk.X) + ttk.Button(btn_frame, text="+ Добавить", command=self._add_is).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="Копировать", command=self._copy_is).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="- Удалить", command=self._delete_is).pack(side=tk.LEFT, padx=2) + + # Правая панель — редактирование + self._is_edit_panel = ttk.LabelFrame(tab, text="Редактирование ИС") + self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) + + fields_frame = ttk.Frame(self._is_edit_panel, padding=10) + fields_frame.pack(fill=tk.X) + + # Основные поля + ttk.Label(fields_frame, text="Наименование:").grid(row=0, column=0, sticky=tk.W, pady=2) + self.is_name_entry = ttk.Entry(fields_frame, width=55) + self.is_name_entry.grid(row=0, column=1, padx=10, sticky=tk.EW) + + ttk.Label(fields_frame, text="Описание:").grid(row=1, column=0, sticky=tk.W, pady=2) + self.is_description_entry = ttk.Entry(fields_frame, width=55) + self.is_description_entry.grid(row=1, column=1, padx=10, sticky=tk.EW) + + # Чекбоксы + chk_frame = ttk.Frame(fields_frame) + 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() + ttk.Checkbutton(chk_frame, text="Интернет", variable=self.is_internet_var).pack(side=tk.LEFT, padx=5) + + # Тип угроз + self.is_threat_var = tk.StringVar(value="3") + 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=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=4, column=0, sticky=tk.W, pady=2) + def_frame = ttk.Frame(fields_frame) + 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.Label(fields_frame, text="Категории ПДн:").grid(row=5, column=0, sticky=tk.NW, pady=2) + cat_frame = ttk.Frame(fields_frame) + 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_var = tk.StringVar(value="менее 100 000") + self.is_pd_count_combo = ttk.Combobox(fields_frame, textvariable=self.is_pd_count_var, + values=["менее 100 000", "более 100 000"], + state='readonly', width=28) + self.is_pd_count_combo.grid(row=6, column=1, padx=10, sticky=tk.W) + self.is_pd_count_combo.bind('<>', lambda e: self._on_categories_or_count_changed()) + + + + fields_frame.columnconfigure(1, weight=1) + + # Вложенные списки + nb2 = ttk.Notebook(self._is_edit_panel) + nb2.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # Список ПДн + pd_tab = ttk.Frame(nb2) + nb2.add(pd_tab, text="Перечень ПДн") + pd_btn_frame = ttk.Frame(pd_tab) + pd_btn_frame.pack(fill=tk.X) + ttk.Button(pd_btn_frame, text="+ Добавить", command=self._add_pd_item).pack(side=tk.LEFT, padx=2) + ttk.Button(pd_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_listbox)).pack(side=tk.LEFT, padx=2) + ttk.Button(pd_btn_frame, text="Выбрать из списка", command=self._select_from_pd_list).pack(side=tk.LEFT, padx=2) + self.pd_listbox = tk.Listbox(pd_tab, height=6) + self.pd_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Субъекты ПДн + subj_tab = ttk.Frame(nb2) + nb2.add(subj_tab, text="Субъекты ПДн") + subj_btn_frame = ttk.Frame(subj_tab) + subj_btn_frame.pack(fill=tk.X) + ttk.Button(subj_btn_frame, text="+ Добавить", command=self._add_pd_subjects_item).pack(side=tk.LEFT, padx=2) + ttk.Button(subj_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_subjects_listbox)).pack(side=tk.LEFT, padx=2) + ttk.Button(subj_btn_frame, text="Выбрать из списка", command=self._select_from_subjects_list).pack(side=tk.LEFT, padx=2) + self.pd_subjects_listbox = tk.Listbox(subj_tab, height=6) + self.pd_subjects_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Действия с ПДн + act_tab = ttk.Frame(nb2) + nb2.add(act_tab, text="Действия с ПДн") + act_btn_frame = ttk.Frame(act_tab) + act_btn_frame.pack(fill=tk.X) + ttk.Button(act_btn_frame, text="+ Добавить", command=self._add_pd_action_item).pack(side=tk.LEFT, padx=2) + ttk.Button(act_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_actions_listbox)).pack(side=tk.LEFT, padx=2) + ttk.Button(act_btn_frame, text="Выбрать из списка", command=self._select_from_actions_list).pack(side=tk.LEFT, padx=2) + self.pd_actions_listbox = tk.Listbox(act_tab, height=6) + self.pd_actions_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # СЗИ + def_tab = ttk.Frame(nb2) + nb2.add(def_tab, text="Средства защиты") + def_btn_frame = ttk.Frame(def_tab) + def_btn_frame.pack(fill=tk.X) + ttk.Button(def_btn_frame, text="+ Добавить", command=self._add_defense_item).pack(side=tk.LEFT, padx=2) + ttk.Button(def_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.defense_tools_listbox)).pack(side=tk.LEFT, padx=2) + ttk.Button(def_btn_frame, text="Выбрать из списка", command=self._select_from_defense_list).pack(side=tk.LEFT, padx=2) + self.defense_tools_listbox = tk.Listbox(def_tab, height=6) + self.defense_tools_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Пользователи + user_tab = ttk.Frame(nb2) + nb2.add(user_tab, text="Пользователи ИС") + self.users_listbox = tk.Listbox(user_tab, height=6) + self.users_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + btnf = ttk.Frame(user_tab) + btnf.pack(pady=3) + ttk.Button(btnf, text="+ Добавить", command=self._add_user_to_is).pack(side=tk.LEFT, padx=2) + ttk.Button(btnf, text="- Удалить", command=self._del_user_from_is).pack(side=tk.LEFT, padx=2) + + self._set_is_edit_state(False) + + # ---------- ВКЛАДКА "КОМИССИЯ" ---------- + def _build_commission_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Комиссия") + + canvas = tk.Canvas(tab, highlightthickness=0) + scrollbar = ttk.Scrollbar(tab, orient=tk.VERTICAL, command=canvas.yview) + inner = ttk.Frame(canvas) + inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=inner, 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(inner, padding=10) + frame.pack(fill=tk.X) + + ttk.Label(frame, text="Состав комиссии", font=('Arial', 11, 'bold')).pack( + anchor=tk.W, pady=(0, 10)) + + # Заголовки + header = ttk.Frame(frame) + header.pack(fill=tk.X) + for w, col in [(20, "Роль"), (30, "Должность"), (25, "ФИО")]: + ttk.Label(header, text=col, font=('', 9, 'bold'), + width=w, anchor=tk.W).pack(side=tk.LEFT, padx=2) + + self._comm_members_frame = ttk.Frame(frame) + self._comm_members_frame.pack(fill=tk.X) + + ttk.Button(frame, text="+ Добавить члена комиссии", + command=self._add_commission_row_ui).pack(pady=10) + + # ---------- ВКЛАДКА "ПОЛЬЗОВАТЕЛИ" ---------- + def _build_users_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Пользователи") + + 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="Сотрудники, имеющие доступ к ПДн", + font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=(0, 10)) + + # Заголовки + header = ttk.Frame(frame) + header.pack(fill=tk.X) + for w, col in [(25, "Должность"), (30, "ФИО")]: + ttk.Label(header, text=col, font=('', 9, 'bold'), + width=w, anchor=tk.W).pack(side=tk.LEFT, padx=2) + + self._employees_frame = ttk.Frame(frame) + self._employees_frame.pack(fill=tk.X, pady=5) + + btn_frame = ttk.Frame(frame) + btn_frame.pack(fill=tk.X, pady=5) + ttk.Button(btn_frame, text="+ Добавить сотрудника", + command=self._add_employee_row_ui).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="- Удалить выбранного", + command=self._delete_employee_selected).pack(side=tk.LEFT, padx=2) + + # Счётчик + self._emp_count_label = ttk.Label(frame, text="Добавлено: 0") + self._emp_count_label.pack(anchor=tk.W, pady=5) + + self._employee_entries = [] + # Загрузка существующих + for emp in self.model.employees_access: + self._add_employee_row( + emp.get('position', ''), + emp.get('fio', ''), + + ) + self._update_emp_count() + + def _add_employee_row(self, position="", fio=""): + """Добавляет одну строку сотрудника.""" + row = ttk.Frame(self._employees_frame) + row.pack(fill=tk.X, pady=2) + + pos_entry = ttk.Entry(row, width=25) + pos_entry.insert(0, position) + pos_entry.pack(side=tk.LEFT, padx=2) + + fio_entry = ttk.Entry(row, width=30) + fio_entry.insert(0, fio) + fio_entry.pack(side=tk.LEFT, padx=2) + + + del_btn = ttk.Button(row, text="✖", width=3, + command=lambda: self._delete_employee_row(row)) + del_btn.pack(side=tk.LEFT, padx=5) + + self._employee_entries.append((row, pos_entry, fio_entry)) + self._update_emp_count() + + def _add_employee_row_ui(self): + self._add_employee_row() + + def _delete_employee_row(self, row_frame): + self._employee_entries = [e for e in self._employee_entries if e[0] != row_frame] + row_frame.destroy() + self._update_emp_count() + + def _delete_employee_selected(self): + # Удаляем последнюю строку, если есть + if self._employee_entries: + row_frame = self._employee_entries[-1][0] + self._delete_employee_row(row_frame) + + def _update_emp_count(self): + count = len(self._employee_entries) + if hasattr(self, '_emp_count_label'): + self._emp_count_label.config(text=f"Добавлено: {count}") + + def _collect_employees(self): + """Собирает данные сотрудников из GUI в модель.""" + self.model.employees_access.clear() + for row, pos_e, fio_e in self._employee_entries: + emp = { + 'position': pos_e.get().strip(), + 'fio': fio_e.get().strip(), + } + if emp['position'] or emp['fio']: + self.model.employees_access.append(emp) + + # ---------- ВКЛАДКА "ГЕНЕРАЦИЯ" ---------- + def _build_generation_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Генерация") + + frame = ttk.Frame(tab, padding=10) + frame.pack(fill=tk.BOTH, expand=True) + + ttk.Label(frame, text="Параметры генерации", font=('Arial', 11, 'bold')).pack( + anchor=tk.W, pady=(0, 10)) + + ttk.Label(frame, text="Папка шаблонов:").pack(anchor=tk.W) + tf = ttk.Frame(frame) + tf.pack(fill=tk.X, pady=(0, 5)) + ttk.Entry(tf, textvariable=self.templates_var).pack(side=tk.LEFT, fill=tk.X, expand=True) + ttk.Button(tf, text="Обзор", command=self.select_templates).pack(side=tk.RIGHT, padx=5) + + ttk.Label(frame, text="Папка результатов:").pack(anchor=tk.W) + sf = ttk.Frame(frame) + sf.pack(fill=tk.X, pady=(0, 5)) + ttk.Entry(sf, textvariable=self.save_var).pack(side=tk.LEFT, fill=tk.X, expand=True) + ttk.Button(sf, text="Обзор", command=self._select_save_dir).pack(side=tk.RIGHT, padx=5) + + ttk.Label(frame, text="Шаблоны для генерации:", font=('Arial', 10, 'bold')).pack( + anchor=tk.W, pady=(10, 0)) + + btn_frame = ttk.Frame(frame) + btn_frame.pack(fill=tk.X, pady=(0, 5)) + ttk.Button(btn_frame, text="Выделить всё", + command=lambda: self._select_templates_all(True)).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(btn_frame, text="Снять всё", + command=lambda: self._select_templates_all(False)).pack(side=tk.LEFT) + + self.template_listbox = tk.Listbox(frame, height=8, selectmode=tk.EXTENDED) + self.template_listbox.pack(fill=tk.BOTH, expand=True, pady=5) + self.template_listbox.bind('<>', self._on_template_select) + + ttk.Button(frame, text="⚡ Сгенерировать выбранные документы", + command=self.generate_all).pack(fill=tk.X, pady=10) + + # ---------- ВКЛАДКА "ЛОГ" ---------- + def _build_log_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Лог") + self.log_text = scrolledtext.ScrolledText(tab, height=10) + self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + self.log_text.insert(tk.END, "Лог готов\n") + self.log_text.configure(state='disabled') + + # ==================== МЕТОДЫ РАБОТЫ С ДАННЫМИ ==================== + def _init_company(self): + """Инициализация модели.""" + self.model = Company() + # 3 члена комиссии по умолчанию + for _ in range(3): + self.model.commission.append(CommissionMember()) + + def _collect_company(self): + """Собирает данные из GUI в модель.""" + c = self.model + c.full_name = self.company_name_entry.get().strip() + c.short_name = self.short_name_entry.get().strip() + for key, entry in self._company_entries.items(): + setattr(c, key, entry.get().strip()) + for key, entry in self._official_entries.items(): + setattr(c, key, entry.get().strip()) + c.contract_number = self.doc_number_var.get() + c.contract_date = self.doc_date_var.get() + + def _collect_is(self): + """Собирает данные ИС из GUI в модель.""" + if self._current_is_idx is not None and self._current_is_idx < len(self.model.information_systems): + 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.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)) + isys.pd_actions_list = list(self.pd_actions_listbox.get(0, tk.END)) + isys.defense_tools_list = list(self.defense_tools_listbox.get(0, tk.END)) + isys.users_list = list(self.users_listbox.get(0, tk.END)) + + def _collect_commission(self): + """Собирает комиссию из GUI.""" + self.model.commission.clear() + for row_frame, role_var, pos_entry, fio_entry in self.commission_entries: + self.model.commission.append( + CommissionMember( + role=role_var.get(), + position=pos_entry.get().strip(), + fio=fio_entry.get().strip(), + ) + ) + + def _company_to_gui(self): + """Заполняет GUI из модели.""" + c = self.model + self.company_name_entry.delete(0, tk.END) + self.company_name_entry.insert(0, c.full_name or "") + self.short_name_entry.delete(0, tk.END) + self.short_name_entry.insert(0, c.short_name or "") + + for key, entry in self._company_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(c, key, "") or "") + + for key, entry in self._official_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(c, key, "") or "") + + self.doc_number_var.set(c.contract_number or "") + self.doc_date_var.set(c.contract_date or "") + + # Склонения + idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6} + for code, entry in self.decl_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(c, f"company_name_{idx_map[code]}", "") or "") + for code, entry in self.short_decl_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(c, f"short_name_{idx_map[code]}", "") or "") + + # Комиссия + for frame in list(self._comm_members_frame.winfo_children()): + frame.destroy() + self.commission_entries.clear() + for member in c.commission: + self._add_commission_row(member.role, member.position, member.fio) + if not self.commission_entries: + for _ in range(3): + self._add_commission_row() + + # ИС + self._refresh_is_listbox() + if self.model.information_systems: + self.is_listbox.selection_set(0) + self._on_is_select() + + # Пользователи + if hasattr(self, '_employee_entries'): + for row_frame, _, _, _ in self._employee_entries: + row_frame.destroy() + self._employee_entries.clear() + for emp in c.employees_access: + self._add_employee_row( + emp.get('position', ''), + emp.get('fio', ''), + ) + self._update_emp_count() + + # Шаблоны + 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, re.IGNORECASE) + 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): + """Автоматический расчёт УЗ по ПП №1119: категории ПДн, тип субъектов, + кол-во записей, тип угроз.""" + if self._current_is_idx is None: + return + + categories = [cat for cat, var in self.category_vars if var.get()] + pd_count = self.is_pd_count_var.get() if hasattr(self, 'is_pd_count_var') else 'менее 100 000' + threat_type = self.is_threat_var.get() if hasattr(self, 'is_threat_var') else '3' + is_large = 'более' in pd_count.lower() + # Автоопределение типа субъектов: если в Субъектах ПДн выбраны только "Сотрудники" → работники, иначе → иные + subj_items = self.pd_subjects_listbox.get(0, tk.END) if hasattr(self, 'pd_subjects_listbox') else [] + has_only_employees = len(subj_items) > 0 and all('сотрудник' in s.lower() for s in subj_items) + is_employees = has_only_employees or len(subj_items) == 0 + + # Таблица ПП №1119: (категория, тип_субъектов, объём, тип_угроз) → УЗ + def lookup(cat_group, employees, large, threat): + # Общедоступные → всегда 4 + if cat_group == 'public': + return 4 + # Специальные/биометрические + if cat_group == 'special_bio': + tbl = { + ('emp', False, '1'): 2, ('emp', True, '1'): 1, + ('other', False, '1'): 1, ('other', True, '1'): 1, + ('emp', False, '2'): 2, ('emp', True, '2'): 1, + ('other', False, '2'): 2, ('other', True, '2'): 1, + ('emp', False, '3'): 3, ('emp', True, '3'): 2, + ('other', False, '3'): 3, ('other', True, '3'): 2, + } + else: # иные + tbl = { + ('emp', False, '1'): 3, ('emp', True, '1'): 2, + ('other', False, '1'): 2, ('other', True, '1'): 1, + ('emp', False, '2'): 4, ('emp', True, '2'): 3, + ('other', False, '2'): 3, ('other', True, '2'): 2, + ('emp', False, '3'): 4, ('emp', True, '3'): 4, + ('other', False, '3'): 4, ('other', True, '3'): 3, + } + key = ('emp' if employees else 'other', large, threat) + return tbl.get(key, 4) + + # Определяем группу категорий + if 'общедоступные' in categories and not any(c in categories for c in ['специальные', 'биометрические', 'иные']): + cat_group = 'public' + elif any(c in categories for c in ['специальные', 'биометрические']): + cat_group = 'special_bio' + else: + cat_group = 'other' + + level = lookup(cat_group, is_employees, is_large, threat_type) + self.is_defence_var.set(str(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) + row.pack(fill=tk.X, pady=2) + + 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) + pos_entry.insert(0, position) + pos_entry.pack(side=tk.LEFT, padx=2) + + fio_entry = ttk.Entry(row, width=25) + fio_entry.insert(0, fio) + fio_entry.pack(side=tk.LEFT, padx=2) + + ttk.Button(row, text="✖", width=3, + command=lambda: self._delete_commission_row(row)).pack(side=tk.LEFT, padx=5) + + self.commission_entries.append((row, role_var, pos_entry, fio_entry)) + + def _add_commission_row_ui(self): + self._add_commission_row() + + def _delete_commission_row(self, row_frame): + self.commission_entries = [e for e in self.commission_entries if e[0] != row_frame] + row_frame.destroy() + + # ==================== ИНФОРМАЦИОННЫЕ СИСТЕМЫ ==================== + def _refresh_is_listbox(self): + self.is_listbox.delete(0, tk.END) + for isys in self.model.information_systems: + self.is_listbox.insert(tk.END, isys.name or "(без названия)") + if not self.model.information_systems: + self._is_edit_panel.pack_forget() + self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) + self._set_is_edit_state(False) + else: + self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) + + def _set_is_edit_state(self, enabled): + state = 'normal' if enabled else 'disabled' + self.is_lan_var.set(False) + self.is_internet_var.set(False) + + def _on_is_select(self, event=None): + sel = self.is_listbox.curselection() + if not sel: + self._current_is_idx = None + return + + # Сохраняем текущую ИС + self._collect_is() + + idx = sel[0] + if idx >= len(self.model.information_systems): + return + self._current_is_idx = idx + isys = self.model.information_systems[idx] + + self.is_name_entry.delete(0, tk.END) + 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_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 [ + (self.pd_listbox, isys.personal_data_list or []), + (self.pd_subjects_listbox, isys.pd_subjects_list or []), + (self.pd_actions_listbox, isys.pd_actions_list or []), + (self.defense_tools_listbox, isys.defense_tools_list or []), + (self.users_listbox, isys.users_list or []), + ]: + lb.delete(0, tk.END) + for item in items: + lb.insert(tk.END, item) + + def _add_is(self): + # Сохраняем текущую ИС перед добавлением новой + self._collect_is() + name = simpledialog.askstring("Добавить ИС", "Введите наименование ИС:") + if name: + isys = InformationSystem(name=name) + self.model.information_systems.append(isys) + self._refresh_is_listbox() + self.is_listbox.selection_set(tk.END) + self._on_is_select() + + def _copy_is(self): + if self._current_is_idx is None: + return + self._collect_is() + src = self.model.information_systems[self._current_is_idx] + copy = InformationSystem( + name=src.name + " (копия)", + description=src.description, + is_local_network=src.is_local_network, + is_internet=src.is_internet, + defence_level=src.defence_level, + subject_type=src.subject_type, + threat_type=src.threat_type, + personal_data_category=list(src.personal_data_category), + personal_data_list=list(src.personal_data_list), + pd_actions_list=list(src.pd_actions_list), + pd_subjects_list=list(src.pd_subjects_list), + defense_tools_list=list(src.defense_tools_list), + users_list=list(src.users_list), + ) + self.model.information_systems.append(copy) + self._refresh_is_listbox() + self.is_listbox.selection_set(tk.END) + self._on_is_select() + + def _delete_is(self): + if self._current_is_idx is None: + return + self._collect_is() + del self.model.information_systems[self._current_is_idx] + self._current_is_idx = None + self._refresh_is_listbox() + if self.model.information_systems: + self.is_listbox.selection_set(0) + self._on_is_select() + + def _add_pd_item(self): + item = simpledialog.askstring("Добавить ПДн", "Введите персональные данные:") + if item: + self.pd_listbox.insert(tk.END, item) + + def _add_pd_subjects_item(self): + item = simpledialog.askstring("Добавить категорию", "Введите категорию субъектов:") + if item: + self.pd_subjects_listbox.insert(tk.END, item) + + def _add_defense_item(self): + item = simpledialog.askstring("Добавить СЗИ", "Введите средство защиты:") + if item: + self.defense_tools_listbox.insert(tk.END, item) + + def _add_user_to_is(self): + user = simpledialog.askstring("Добавить пользователя", "Введите ФИО или должность:") + if user: + self.users_listbox.insert(tk.END, user) + + def _del_user_from_is(self): + sel = self.users_listbox.curselection() + if sel: + self.users_listbox.delete(sel[0]) + + def _del_from_listbox(self, listbox): + """Удаление выбранного элемента из любого listbox.""" + sel = listbox.curselection() + if sel: + listbox.delete(sel[0]) + + # ==================== ВЫБОР ИЗ СПИСКА (словари) ==================== + def _select_from_dialog(self, title: str, items: list, listbox): + """Открывает окно с чекбоксами для выбора элементов из списка.""" + dialog = tk.Toplevel(self.root) + dialog.title(title) + dialog.geometry("400x500") + dialog.transient(self.root) + dialog.grab_set() + + frame = ttk.Frame(dialog, padding=10) + frame.pack(fill=tk.BOTH, expand=True) + + ttk.Label(frame, text=title, font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=(0, 5)) + + # Уже выбранные элементы + existing = set(listbox.get(0, tk.END)) + + canvas = tk.Canvas(frame, highlightthickness=0) + scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas.yview) + check_frame = ttk.Frame(canvas) + check_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=check_frame, 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) + + vars = {} + for item in items: + var = tk.BooleanVar(value=item in existing) + cb = ttk.Checkbutton(check_frame, text=item, variable=var) + cb.pack(anchor=tk.W, pady=1) + vars[item] = var + + btn_frame = ttk.Frame(dialog, padding=10) + btn_frame.pack(fill=tk.X) + + def _apply(): + selected = [item for item, var in vars.items() if var.get()] + listbox.delete(0, tk.END) + for item in selected: + listbox.insert(tk.END, item) + dialog.destroy() + + def _select_all(): + for var in vars.values(): + var.set(True) + + def _clear_all(): + for var in vars.values(): + var.set(False) + + ttk.Button(btn_frame, text="Выбрать всё", command=_select_all).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="Снять всё", command=_clear_all).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="✅ Применить", command=_apply).pack(side=tk.RIGHT, padx=2) + + dialog.wait_window() + + def _add_pd_action_item(self): + item = simpledialog.askstring("Добавить действие", "Введите действие с ПДн:") + if item: + self.pd_actions_listbox.insert(tk.END, item) + + def _select_from_actions_list(self): + """Выбор действий с ПДн из JSON словаря.""" + import json + dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_actions.json') + try: + with open(dict_path, 'r', encoding='utf-8') as f: + items = json.load(f) + self._select_from_dialog("Выбор действий с персональными данными", items, self.pd_actions_listbox) + except Exception as e: + messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}") + + def _select_from_pd_list(self): + """Выбор ПДн из JSON словаря.""" + import json + dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_items.json') + try: + with open(dict_path, 'r', encoding='utf-8') as f: + items = json.load(f) + self._select_from_dialog("Выбор персональных данных", items, self.pd_listbox) + except Exception as e: + messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}") + + def _select_from_subjects_list(self): + """Выбор категорий субъектов из JSON словаря.""" + import json + dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_categories.json') + try: + with open(dict_path, 'r', encoding='utf-8') as f: + items = json.load(f) + self._select_from_dialog("Выбор категорий субъектов ПДн", items, self.pd_subjects_listbox) + except Exception as e: + messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}") + + def _select_from_defense_list(self): + """Выбор СЗИ из JSON словаря.""" + import json + dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'defense_tools.json') + try: + with open(dict_path, 'r', encoding='utf-8') as f: + items = json.load(f) + self._select_from_dialog("Выбор средств защиты информации", items, self.defense_tools_listbox) + except Exception as e: + messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}") + + # ==================== ШАБЛОНЫ ==================== + def select_templates(self): + folder = filedialog.askdirectory(title="Папка шаблонов") + if folder: + self.templates_var.set(folder) + self._on_templates_changed() + + def _select_save_dir(self): + folder = filedialog.askdirectory(title="Папка для результатов") + if folder: + self.save_var.set(folder) + + def _on_templates_changed(self, *_): + """Обновить список шаблонов при смене папки.""" + folder = self.templates_var.get() + self.template_listbox.delete(0, tk.END) + self._template_files = [] + if folder and os.path.isdir(folder): + for f in sorted(os.listdir(folder)): + if f.endswith('.docx') and not f.endswith('_result.docx'): + self.template_listbox.insert(tk.END, f) + self._template_files.append(f) + # Выделяем все по умолчанию + self.template_listbox.selection_set(0, tk.END) + self._selected_templates = set(self._template_files) + self.log(f"Найдено шаблонов: {len(self._template_files)}") + + def _on_template_select(self, event=None): + sel = self.template_listbox.curselection() + self._selected_templates = {self._template_files[i] for i in sel} + + def _select_templates_all(self, select: bool): + if select: + self.template_listbox.selection_set(0, tk.END) + else: + self.template_listbox.selection_clear(0, tk.END) + self._on_template_select() + + # ==================== ГЕНЕРАЦИЯ ==================== + def generate_all(self): + """Генерация всех выбранных документов.""" + templates_dir = self.templates_var.get() + output_dir = self.save_var.get() + + if not templates_dir or not os.path.isdir(templates_dir): + messagebox.showerror("Ошибка", "Выберите папку с шаблонами") + return + if not output_dir: + output_dir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "Сгенерированные_документы" + ) + self.save_var.set(output_dir) + + selected = self._selected_templates + if not selected: + messagebox.showwarning("Предупреждение", "Не выбран ни один шаблон") + return + + # Собираем данные + self._collect_company() + self._collect_commission() + self._collect_is() + self._collect_employees() + + # Проверка + if not self._validate_before_generate(): + return + + 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: + replacements = build_replacements( + company=self.model, + is_list=self.model.information_systems, + doc_number=self.doc_number_var.get(), + doc_date=self.doc_date_var.get(), + direction='152fz', + ) + + results = [] + for fname in selected: + template_path = os.path.join(templates_dir, fname) + if not os.path.isfile(template_path): + self.log(f"❌ Файл не найден: {fname}") + continue + + base = os.path.splitext(fname)[0] + output_path = os.path.join(output_dir, f"{base}.docx") + + try: + process_template(template_path, replacements, output_path, self.log) + results.append(output_path) + except Exception as e: + 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() + + def _validate_before_generate(self): + warnings = [] + if not self.model.full_name: + warnings.append("Полное наименование организации") + if not self.model.inn: + warnings.append("ИНН") + if not self.model.chief_fio: + warnings.append("ФИО руководителя") + if not self.model.information_systems: + warnings.append("Нет информационных систем") + if warnings: + msg = "Не заполнены:\n" + "\n".join(warnings) + return messagebox.askyesno("Внимание", msg + "\n\nПродолжить?") + return True + + # ==================== СКЛОНЕНИЕ ==================== + def _update_company_declensions(self): + name = self.company_name_entry.get().strip() + if not name: + return + self.model.full_name = name + self.model.update_declensions(inflect_name) + idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6} + for code, entry in self.decl_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(self.model, f"company_name_{idx_map[code]}", "")) + self.log("✅ Склонения полного названия обновлены") + + def _update_short_declensions(self): + name = self.short_name_entry.get().strip() + if not name: + return + self.model.short_name = name + self.model.update_short_declensions(inflect_name) + idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6} + for code, entry in self.short_decl_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(self.model, f"short_name_{idx_map[code]}", "")) + self.log("✅ Склонения краткого названия обновлены") + + # ==================== ИМПОРТ ==================== + def import_from_survey(self): + path = filedialog.askopenfilename( + title="Выберите опросный лист (152-ФЗ)", + filetypes=[("Excel", "*.xlsx *.xls")] + ) + if not path: + return + self.log(f"Импорт: {path}") + + # Импорт в отдельном потоке + def _run(): + try: + from .importer import import_152fz + data = import_152fz(path, self.log) + if data: + self._apply_imported_data(data) + except ImportError: + self.log("❌ Модуль импорта не найден") + except Exception as e: + self.log(f"❌ Ошибка импорта: {e}") + + threading.Thread(target=_run, daemon=True).start() + + def _apply_imported_data(self, data: dict): + def _apply(): + # Название организации + if data.get('fullName'): + self.company_name_entry.delete(0, tk.END) + self.company_name_entry.insert(0, data['fullName']) + self.model.full_name = data['fullName'] + if data.get('shortName'): + self.short_name_entry.delete(0, tk.END) + self.short_name_entry.insert(0, data['shortName']) + self.model.short_name = data['shortName'] + + # Адрес и реквизиты (маппинг camelCase → snake_case) + addr_map = { + 'address': 'addressLegal', + 'city_name': None, + 'inn': 'inn', + 'kpp': 'kpp', + 'ogrn': 'ogrn', + 'ogrn_date': 'ogrnDate', + } + for entry_key, data_key in addr_map.items(): + entry = self._company_entries.get(entry_key) + if not entry: + continue + val = '' + if data_key and data.get(data_key): + val = data[data_key] + elif data.get(entry_key): + val = data[entry_key] + if val: + entry.delete(0, tk.END) + entry.insert(0, val) + + # Автоопределение города из адреса после импорта + self._on_address_changed() + + # Должностные лица (маппинг) + officials_map = { + 'chief_position': 'chiefPosition', + 'chief_fio': 'chiefFio', + 'responsible_position': 'responsiblePosition', + 'responsible_fio': 'responsibleFio', + 'admin_position': ('administratorPosition', 'ispdnPosition'), + 'admin_fio': ('administratorFio', 'ispdnFio'), + } + for entry_key, data_keys in officials_map.items(): + entry = self._official_entries.get(entry_key) + if not entry: + continue + val = '' + if isinstance(data_keys, tuple): + for dk in data_keys: + if data.get(dk): + val = data[dk] + break + elif data.get(data_keys): + val = data[data_keys] + if val: + entry.delete(0, tk.END) + entry.insert(0, val) + + # Номер и дата документа + if data.get('contractNumber'): + self.doc_number_var.set(data['contractNumber']) + if data.get('contractDate'): + self.doc_date_var.set(data['contractDate']) + + # Информационные системы + if data.get('informationSystems'): + self.model.information_systems.clear() + for is_data in data['informationSystems']: + isys = InformationSystem( + name=is_data.get('name', ''), + description=is_data.get('description', ''), + is_internet=is_data.get('isInternet', False), + pd_count=is_data.get('personalDataCount', 'менее 100 000'), + personal_data_list=is_data.get('personalDataList', []), + pd_subjects_list=is_data.get('pd_subjects_list', []), + defense_tools_list=is_data.get('defense_tools', []), + users_list=is_data.get('usersList', []), + personal_data_category=is_data.get('personalDataCategory', []), + processing_modes=is_data.get('processing_mode', ''), + purpose=is_data.get('description', ''), + room=is_data.get('room', ''), + ) + self.model.information_systems.append(isys) + self._refresh_is_listbox() + if self.model.information_systems: + self.is_listbox.selection_set(0) + self._on_is_select() + + # Комиссия + if data.get('commission'): + self.model.commission.clear() + # Очистим UI + for row_frame, _, _, _ in self.commission_entries: + row_frame.destroy() + self.commission_entries.clear() + for m in data['commission']: + self.model.commission.append( + CommissionMember( + role=m.get('role', 'Член комиссии'), + position=m.get('position', ''), + fio=m.get('fio', ''), + ) + ) + self._add_commission_row( + m.get('role', 'Член комиссии'), + m.get('position', ''), + m.get('fio', ''), + ) + + # Сотрудники с доступом + if data.get('employeesAccess'): + self.model.employees_access.clear() + if hasattr(self, '_employee_entries'): + for row_frame, _, _, _ in self._employee_entries: + row_frame.destroy() + self._employee_entries.clear() + for emp in data['employeesAccess']: + self.model.employees_access.append({ + 'position': emp.get('position', ''), + 'fio': emp.get('fio', ''), + }) + self._add_employee_row( + emp.get('position', ''), + emp.get('fio', ''), + ) + self._update_emp_count() + + self.log("✅ Импорт применён") + self.root.after(0, _apply) + + # ==================== ФАЙЛОВЫЕ ОПЕРАЦИИ ==================== + def new_data(self): + if self.model.full_name: + if not messagebox.askyesno("Новое", "Текущие данные будут потеряны. Продолжить?"): + return + self._init_company() + self.current_file = None + self._company_to_gui() + self.log("Новые данные") + + def open_data(self): + filename = filedialog.askopenfilename(filetypes=[("JSON", "*.json")]) + if filename: + try: + with open(filename, 'r', encoding='utf-8') as f: + data = json.load(f) + self.model = Company.from_dict(data) + self.current_file = filename + self._company_to_gui() + self.log(f"Загружено: {filename}") + except Exception as e: + messagebox.showerror("Ошибка", str(e)) + + def save_data(self): + if self.current_file: + self._save_to(self.current_file) + else: + self.save_as_data() + + def save_as_data(self): + filename = filedialog.asksaveasfilename( + defaultextension=".json", filetypes=[("JSON", "*.json")] + ) + if filename: + self._save_to(filename) + + def _save_to(self, filename): + self._collect_company() + self._collect_commission() + self._collect_is() + self._collect_employees() + try: + with open(filename, 'w', encoding='utf-8') as f: + json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2) + self.current_file = filename + self.log(f"Сохранено: {filename}") + except Exception as e: + messagebox.showerror("Ошибка", str(e)) + + # ==================== АВТОСОХРАНЕНИЕ ==================== + def _start_autosave_timer(self): + self._autosave() + self._autosave_timer_id = self.root.after(120000, self._start_autosave_timer) + + def _stop_autosave_timer(self): + if hasattr(self, '_autosave_timer_id') and self._autosave_timer_id: + try: + self.root.after_cancel(self._autosave_timer_id) + except: + pass + + def _autosave(self): + self._collect_company() + self._collect_commission() + self._collect_is() + self._collect_employees() + try: + os.makedirs(os.path.dirname(self._autosave_path), exist_ok=True) + with open(self._autosave_path, 'w', encoding='utf-8') as f: + json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2) + except Exception: + pass + + def _load_autosave(self): + try: + if not os.path.isfile(self._autosave_path): + return + mtime = os.path.getmtime(self._autosave_path) + if datetime.now().timestamp() - mtime > 86400: + return + with open(self._autosave_path, 'r', encoding='utf-8') as f: + data = json.load(f) + self.model = Company.from_dict(data) + self._company_to_gui() + self.log("📂 Данные восстановлены из автосохранения") + except Exception: + pass + + def _on_close(self): + self._stop_autosave_timer() + self._autosave() + self.root.destroy() + + # ==================== КОНТЕКСТНОЕ МЕНЮ ==================== + def _enable_context_menu(self): + def _show_menu(event): + widget = event.widget + if not isinstance(widget, (tk.Entry, ttk.Entry, tk.Text, tk.Listbox)): + return + menu = tk.Menu(self.root, tearoff=0) + def _copy(): + try: + text = "" + if isinstance(widget, (tk.Entry, ttk.Entry)): + text = widget.selection_get() if widget.selection_present() else "" + elif isinstance(widget, tk.Text): + text = widget.selection_get() if widget.tag_ranges(tk.SEL) else "" + if text: + self.root.clipboard_clear() + self.root.clipboard_append(text) + except: + pass + def _paste(): + try: + text = self.root.clipboard_get() + if not text: return + if isinstance(widget, (tk.Entry, ttk.Entry)): + try: widget.delete(tk.SEL_FIRST, tk.SEL_LAST) + except: pass + widget.insert(tk.INSERT, text) + elif isinstance(widget, tk.Text): + try: + if widget.tag_ranges(tk.SEL): + widget.delete(tk.SEL_FIRST, tk.SEL_LAST) + except: pass + widget.insert(tk.INSERT, text) + except: + pass + menu.add_command(label="Копировать", command=_copy) + menu.add_command(label="Вставить", command=_paste) + menu.add_separator() + menu.add_command(label="Выделить всё", command=lambda: widget.select_range(0, tk.END) if hasattr(widget, 'select_range') else None) + menu.tk_popup(event.x_root, event.y_root) + self.root.bind_all('', _show_menu, add='+') + + # ==================== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ==================== + def log(self, message: str): + ts = datetime.now().strftime("%H:%M:%S") + try: + self.log_text.configure(state='normal') + self.log_text.insert(tk.END, f"[{ts}] {message}\n") + self.log_text.see(tk.END) + self.log_text.configure(state='disabled') + except Exception: + pass + self.status_var.set(message) + + def show_about(self): + messagebox.showinfo("О программе", + "ДоКоГеНеРаТоР — 152-ФЗ\n" + "Версия 1.0\n" + "Генератор документов из шаблонов") + + def show_variables(self): + text = "Переменные шаблонов:\n\n" + for var, group, desc, _ in VARIABLE_DEFS: + text += f"{var} — {desc} ({'общ.' if group == 'A' else '152-ФЗ'})\n" + messagebox.showinfo("Переменные шаблонов", text) + + +def main(): + """Точка входа в приложение.""" + root = tk.Tk() + app = DokoGenApp(root) + root.mainloop() diff --git a/dokogen/variables.py b/dokogen/variables.py new file mode 100644 index 0000000..44e8c05 --- /dev/null +++ b/dokogen/variables.py @@ -0,0 +1,395 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Справочник переменных шаблонов и резолвер значений""" + +from datetime import datetime +from typing import Dict, Any, List, Optional +import re + +from . import declension as dc +from .models import Company, CommissionMember + + +# ============================================================ +# ОПИСАНИЕ ПЕРЕМЕННЫХ +# ============================================================ +# Формат: (имя_переменной, группа, описание, ключ_резолвера) +# Группы: A — общие, B — 152-ФЗ + +VARIABLE_DEFS = [ + # === Полное название организации (6 падежей) === + ('{{company_full_name}}', 'A', 'Полное наименование организации (им.п.)', 'company:full_name'), + ('{{company_full_name_genitive}}', 'A', 'Полное наименование (род.п.)', 'decl:full_name:gent'), + ('{{company_full_name_dative}}', 'A', 'Полное наименование (дат.п.)', 'decl:full_name:datv'), + ('{{company_full_name_accs}}', 'A', 'Полное наименование (вин.п.)', 'decl:full_name:accs'), + ('{{company_full_name_ablt}}', 'A', 'Полное наименование (твор.п.)', 'decl:full_name:ablt'), + ('{{company_full_name_loct}}', 'A', 'Полное наименование (пр.п.)', 'decl:full_name:loct'), + + # === Краткое название организации (6 падежей) === + ('{{company_short_name}}', 'A', 'Краткое наименование (им.п.)', 'company:short_name'), + ('{{company_short_name_genitive}}', 'A', 'Краткое наименование (род.п.)', 'decl:short_name:gent'), + ('{{company_short_name_dative}}', 'A', 'Краткое наименование (дат.п.)', 'decl:short_name:datv'), + ('{{company_short_name_accs}}', 'A', 'Краткое наименование (вин.п.)', 'decl:short_name:accs'), + ('{{company_short_name_ablt}}', 'A', 'Краткое наименование (твор.п.)', 'decl:short_name:ablt'), + ('{{company_short_name_loct}}', 'A', 'Краткое наименование (пр.п.)', 'decl:short_name:loct'), + + # === Должностные лица === + ('{{chief_position}}', 'A', 'Должность руководителя', 'company:chief_position'), + ('{{chief_fio_initials}}', 'A', 'И.О. Фамилия руководителя', 'initials:chief'), + ('{{chief_fio_short}}', 'A', 'Фамилия И.О. руководителя', 'short_fio:chief'), + ('{{responsible_position}}', 'A', 'Должность ответственного', 'company:responsible_position'), + ('{{responsible_fio_initials}}', 'A', 'И.О. Фамилия ответственного', 'initials:responsible'), + ('{{responsible_fio_short}}', 'A', 'Фамилия И.О. ответственного', 'short_fio:responsible'), + ('{{admin_position}}', 'A', 'Должность администратора', 'company:admin_position'), + ('{{admin_fio_initials}}', 'A', 'И.О. Фамилия администратора', 'initials:admin'), + ('{{admin_fio_short}}', 'A', 'Фамилия И.О. администратора', 'short_fio:admin'), + + # === Сведения об организации === + ('{{company_address}}', 'A', 'Адрес организации', 'company:address'), + ('{{city_name}}', 'A', 'Город', 'company:city_name'), + ('{{company_inn}}', 'A', 'ИНН', 'company:inn'), + ('{{company_ogrn}}', 'A', 'ОГРН', 'company:ogrn'), + ('{{company_ogrn_date}}', 'A', 'Дата ОГРН', 'company:ogrn_date'), + ('{{company_kpp}}', 'A', 'КПП', 'company:kpp'), + + # === Комиссия === + ('{{commission_list}}', 'A', 'Список комиссии', 'commission_list'), + + # === Специальные переменные === + ('{{date_year}}', 'A', 'Текущий год', 'date:year'), + ('{{date}}', 'A', 'Текущая дата', 'date:full'), + ('{{document_date}}', 'A', 'Дата документа', 'doc_date'), + ('{{document_number}}', 'A', 'Номер документа', 'doc_number'), + + # === 152-ФЗ === + ('{{is_security_requirements}}', 'B', 'Требования уровня защищённости', 'security_reqs'), + ('{{is_list}}', 'B', 'Список всех ИС (нумерованный)', 'is_list'), + ('{{is_name}}', 'B', 'Наименование первой ИС', 'is:name'), + ('{{name}}', 'B', 'Название ИС (алиас)', 'is:name'), + ('{{description}}', 'B', 'Описание ИС (алиас)', 'is:description'), + ('{{category}}', 'B', 'Категория ИС (алиас)', 'is:category'), + ('{{number}}', 'B', 'Номер (алиас)', 'doc_number'), + ('{{pd_count}}', 'B', 'Количество записей ПДн (алиас)', 'is:pd_count'), + ('{{pd_list}}', 'B', 'Список ПДн (алиас)', 'is:pd_list'), + ('{{pd_subjects}}', 'B', 'Субъекты ПДн (алиас)', 'is:pd_subjects'), + ('{{threat_type}}', 'B', 'Тип угроз (алиас)', 'is:threat_type'), + ('{{users}}', 'B', 'Пользователи ИС (алиас)', 'is:users'), + ('{{defence_level}}', 'B', 'Уровень защищённости (алиас)', 'is:defence_level'), + ('{{full_name_genitive}}', 'A', 'Полное наименование (род.п., алиас)', 'decl:full_name:gent'), + ('{{document}}', 'A', 'Название бумажного документа (алиас)', 'company:paper_documents'), + ('{{storage}}', 'A', 'Место хранения (алиас)', 'company:paper_storage'), + ('{{responsible_fio_accs}}', 'A', 'ФИО ответственного (вин.п.)', 'fio_acl:responsible'), + ('{{commission1}}', 'A', 'Должность члена комиссии 1', 'commission_member:0:position'), + ('{{commission1_fio}}', 'A', 'ФИО члена комиссии 1', 'commission_member:0:fio'), + ('{{commission2}}', 'A', 'Должность члена комиссии 2', 'commission_member:1:position'), + ('{{commission2_fio}}', 'A', 'ФИО члена комиссии 2', 'commission_member:1:fio'), + ('{{commission3}}', 'A', 'Должность члена комиссии 3', 'commission_member:2:position'), + ('{{commission3_fio}}', 'A', 'ФИО члена комиссии 3', 'commission_member:2:fio'), +] + +# Маппинг старых переменных <...> → {{...}} +OLD_VAR_MAP = { + '': '{{company_full_name}}', + '': '{{company_short_name}}', + '': '{{company_full_name_genitive}}', + '': '{{company_short_name_genitive}}', + '': '{{company_full_name_dative}}', + '': '{{company_short_name_dative}}', + '': '{{company_full_name_accs}}', + '': '{{company_short_name_accs}}', + '': '{{company_full_name_ablt}}', + '': '{{company_short_name_ablt}}', + '': '{{company_full_name_loct}}', + '': '{{company_short_name_loct}}', + '
': '{{company_address}}', + '': '{{company_inn}}', + '': '{{company_ogrn}}', + '': '{{company_kpp}}', + '': '{{chief_fio_initials}}', + '': '{{chief_fio_short}}', + '': '{{chief_position}}', + '': '{{responsible_fio_initials}}', + '': '{{responsible_fio_short}}', + '': '{{responsible_position}}', + '': '{{commission_list}}', + '': '{{document_number}}', + '': '{{document_date}}', + '': '{{date}}', + '': '{{document_date}}', +} + +VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}') +OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>') + + +# ============================================================ +# ПОСТРОЕНИЕ СЛОВАРЯ ЗАМЕН +# ============================================================ + +def build_replacements( + company: Company, + is_list: List = None, + doc_number: str = "", + doc_date: str = "", + direction: str = "152fz", +) -> Dict[str, str]: + """Строит полный словарь замен {{переменная}} → значение.""" + if is_list is None: + is_list = [] + + # ---------- Комиссия ---------- + commission_data = _build_commission_data(company) + + # ---------- Контекст ---------- + ctx = { + 'company': company, + 'is_list': is_list, + 'commission_data': commission_data, + 'doc_number': doc_number or '', + 'doc_date': doc_date or '', + 'direction': direction, + } + + replacements = {} + for var_name, group, desc, source in VARIABLE_DEFS: + if direction == '152fz' and group not in ('A', 'B'): + continue + value = _resolve(source, ctx) + replacements[var_name] = str(value) if value else '—' + + # Индексированные переменные ИС (is_name_1, is_pd_list_2 и т.д.) + for i, isys in enumerate(is_list, 1): + _add_is_indexed_vars(replacements, isys, i) + + # Циклы + replacements['_loops'] = _build_loop_data(company, is_list) + return replacements + + +def _build_commission_data(company: Company) -> Dict[str, str]: + data = {} + members = company.commission or [] + + lines = [] + for i, m in enumerate(members, 1): + role = m.role or 'член комиссии' + line = f"{i}. {role}: {m.position} — {m.fio}" if m.position else f"{i}. {role}: {m.fio}" + lines.append(line) + data['commission_list'] = '\n'.join(lines) if lines else '—' + + return data + + +def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int): + fields = { + 'is_name': isys.name or '—', + 'is_address': getattr(isys, 'address', '') or '—', + 'is_description': isys.description or '—', + 'is_has_internet': 'да' if isys.is_internet else 'нет', + 'is_defence_level': str(isys.defence_level or '—'), + 'is_category': isys.category or '—', + 'is_pd_list': ', '.join(isys.personal_data_list or []) or '—', + 'is_users': ', '.join(isys.users_list or []) or '—', + 'is_pd_count': str(isys.pd_count or '—'), + 'is_purpose': isys.purpose or '—', + } + for f, v in fields.items(): + replacements[f'{{{{{f}_{idx}}}}}'] = str(v) + + +def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]: + loops = {} + + # Комиссия + members = company.commission or [] + if members: + items = [] + for m in members: + items.append({ + 'role': m.role or 'член комиссии', + 'position': m.position or '—', + 'fio': m.fio or '—', + 'full': f"{m.role} {m.position} — {m.fio}" if m.position else f"{m.role} — {m.fio}", + }) + loops['commission'] = items + + # ИС + if is_list: + items = [] + for isys in is_list: + items.append({ + 'name': isys.name or '—', + 'description': isys.description or '—', + 'is_has_lan': 'Да' if isys.is_local_network else 'Нет', + 'has_internet': 'да' if isys.is_internet else 'нет', + 'defence_level': str(isys.defence_level or '—'), + 'threat_type': str(isys.threat_type or '3'), + 'category': isys.category or '—', + 'pd_list': ', '.join(isys.personal_data_list or []) or '—', + 'pd_count': str(isys.pd_count or '—'), + 'users': ', '.join(isys.users_list or []) or '—', + 'defense_tools_list': '; '.join(isys.defense_tools_list or []) or '—', + 'processing_modes': isys.processing_modes or '—', + 'pd_subjects': '; '.join(isys.pd_subjects_list or []) or '—', + 'is_purpose': isys.purpose or '—', + }) + loops['information_systems'] = items + + # Бумажные документы + paper_list = getattr(company, 'paper_documents_list', None) or [] + if paper_list: + items = [] + for p in paper_list: + items.append({ + 'document': p.name or '', + 'storage': p.storage or '', + }) + loops['paper_documents'] = items + + # Сотрудники с доступом + employees = getattr(company, 'employees_access', None) or [] + if employees: + items = [] + for e in employees: + items.append({ + 'position': e.get('position', ''), + 'fio': e.get('fio', ''), + }) + loops['employees_access'] = items + + return loops + + +# ============================================================ +# РЕЗОЛВЕР ЗНАЧЕНИЙ +# ============================================================ + +def _resolve(source: str, ctx: Dict) -> str: + """Преобразует ключ-источник в значение, подставляя из контекста.""" + company = ctx.get('company') + is_list = ctx.get('is_list', []) + commission_data = ctx.get('commission_data', {}) + doc_number = ctx.get('doc_number', '') + doc_date = ctx.get('doc_date', '') + + # Прямые поля Company + if source.startswith('company:'): + field = source[8:] + return str(getattr(company, field, '') or '') + + # Склонение названия + if source.startswith('decl:'): + _, field, case = source.split(':') + name = getattr(company, field, '') or '' + return dc.company_name_decline(name, case) if name else '—' + + # Форматы ФИО + if source.startswith('short_fio:'): + who = source.split(':', 1)[1] + fio = _get_fio(company, who) + return dc.get_short_fio(fio) if fio else '—' + if source.startswith('initials:'): + who = source.split(':', 1)[1] + fio = _get_fio(company, who) + return dc.get_initials(fio) if fio else '—' + + # Комиссия + if source in commission_data: + return str(commission_data[source]) + + # Даты + if source.startswith('date:'): + fmt = source.split(':', 1)[1] + now = datetime.now() + if fmt == 'year': + return str(now.year) + return now.strftime('%d.%m.%Y') + if source == 'doc_date': + return doc_date or datetime.now().strftime('%d.%m.%Y') + if source == 'doc_number': + num = str(doc_number).strip() + return re.sub(r'^[Nn]?[oо]?[№#]\s*', '', num) or '—' + + # Требования защищённости + if source == 'security_reqs': + return _get_security_text(ctx) + + # IS-алиасы (первая ИС) + if source.startswith('is:'): + field = source[3:] + if is_list: + first = is_list[0] + field_map = { + 'name': 'name', + 'description': 'description', + 'category': 'category', + 'defence_level': 'defence_level', + 'threat_type': 'threat_type', + 'pd_list': lambda: ', '.join(first.personal_data_list or []), + 'pd_count': 'pd_count', + 'pd_subjects': lambda: '; '.join(first.pd_subjects_list or []), + 'users': lambda: ', '.join(first.users_list or []), + } + if field in field_map: + val = field_map[field] + if callable(val): + return val() or '—' + return str(getattr(first, val, '') or '—') if isinstance(val, str) else '—' + return '—' + + # Список ИС + if source == 'is_list': + if is_list: + return '\n'.join(f"{i}. {isys.name}" for i, isys in enumerate(is_list, 1)) + return '—' + if source == 'is_list_comma': + if is_list: + return ', '.join(isys.name for isys in is_list) + return '—' + + # Члены комиссии + if source.startswith('commission_member:'): + parts = source.split(':') + idx, field = int(parts[1]), parts[2] + members = getattr(company, 'commission', None) or [] + if idx < len(members): + return str(getattr(members[idx], field, '') or '—') + return '—' + + # Склонение ФИО по падежам + if source.startswith('fio_acl:'): + who = source.split(':', 1)[1] + fio = _get_fio(company, who) + return dc.decline(fio, 'accs') if fio else '—' + + return '—' + + +def _get_fio(company: Company, who: str) -> str: + mapping = { + 'chief': 'chief_fio', + 'responsible': 'responsible_fio', + 'admin': 'admin_fio', + } + field = mapping.get(who, who + '_fio') + return getattr(company, field, '') or '' + + +def _get_security_text(ctx: Dict) -> str: + """Возвращает текст требований для уровня защищённости.""" + defence_level = ctx.get('extended_fields', {}).get('defence_level', '4') + is_list = ctx.get('is_list', []) + if not defence_level and is_list: + defence_level = str(getattr(is_list[0], 'defence_level', '4') or '4') + dl = str(defence_level).strip()[0] + + TEXTS = { + '1': """Для обеспечения 1-го уровня защищённости...""", + '2': """Для обеспечения 2-го уровня защищённости...""", + '3': """Для обеспечения 3-го уровня защищённости...""", + '4': """Для обеспечения 4-го уровня защищённости персональных данных необходимо: +— режим безопасности помещений; +— сохранность носителей ПДн; +— утверждение перечня лиц с доступом к ПДн; +— использование сертифицированных СЗИ.""", + } + return TEXTS.get(dl, TEXTS['4']) diff --git a/main.py b/main.py new file mode 100644 index 0000000..2a3cce2 --- /dev/null +++ b/main.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""DokoGen — Генератор документов 152-ФЗ +Точка входа: python main.py +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from dokogen.ui import main + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..98a2cec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +python-docx>=1.1.0 +pymorphy3>=2.0.0 +openpyxl>=3.1.0 +lxml>=5.0.0