From 5315d92b59f4cd1577de800c7b6df002f8e9b70b Mon Sep 17 00:00:00 2001 From: DokoGen Date: Thu, 30 Jul 2026 13:52:18 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82?= =?UTF-8?q?=D1=83=D1=80=D0=B0=20+=20main.py=20=D0=B2=20=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Пакет dokogen/ в подпапке - main.py и requirements.txt в корне - Импорт: from dokogen.ui import main --- .gitignore | 6 + dokogen/__init__.py | 10 + dokogen/__main__.py | 13 + dokogen/declension.py | 222 +++++++++ dokogen/generator.py | 378 +++++++++++++++ dokogen/models.py | 144 ++++++ dokogen/ui.py | 1052 +++++++++++++++++++++++++++++++++++++++++ dokogen/variables.py | 327 +++++++++++++ main.py | 12 + requirements.txt | 4 + 10 files changed, 2168 insertions(+) create mode 100644 .gitignore create mode 100644 dokogen/__init__.py create mode 100644 dokogen/__main__.py create mode 100644 dokogen/declension.py create mode 100644 dokogen/generator.py create mode 100644 dokogen/models.py create mode 100644 dokogen/ui.py create mode 100644 dokogen/variables.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8808c86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +__pycache__/ +.autosave/ +Сгенерированные_документы/ +*.result.docx + diff --git a/dokogen/__init__.py b/dokogen/__init__.py new file mode 100644 index 0000000..cb75f24 --- /dev/null +++ b/dokogen/__init__.py @@ -0,0 +1,10 @@ +# -*- 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 + +__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/generator.py b/dokogen/generator.py new file mode 100644 index 0000000..d312901 --- /dev/null +++ b/dokogen/generator.py @@ -0,0 +1,378 @@ +# -*- 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. Плоская замена переменных (2 прохода) + for _ in range(2): + _replace_in_paragraphs(doc.paragraphs, replacements) + _replace_in_tables(doc.tables, replacements) + _replace_in_headers_footers(doc, replacements) + + # 6. Подсветка незаполненных + _highlight_unmatched(doc) + + # 7. Сохранение + 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 + + +# ==================== ПОДСВЕТКА НЕЗАПОЛНЕННЫХ ==================== + +def _highlight_unmatched(doc): + """Подсвечивает незаполненные переменные красным.""" + ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' + + 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 '{{' in run.text and '}}' in 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 '{{' in run.text and '}}' in 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 '{{' in run.text and '}}' in run.text: + _highlight_run(run) diff --git a/dokogen/models.py b/dokogen/models.py new file mode 100644 index 0000000..e47cbe1 --- /dev/null +++ b/dokogen/models.py @@ -0,0 +1,144 @@ +# -*- 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 = "" + software: 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 = "" + 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) + + 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"] + ] + 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..65a85b4 --- /dev/null +++ b/dokogen/ui.py @@ -0,0 +1,1052 @@ +# -*- 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="001") + self.doc_date_var = tk.StringVar(value=datetime.now().strftime("%d.%m.%Y")) + 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_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 + 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) + + ttk.Label(fields_frame, text="ПО:").grid(row=2, column=0, sticky=tk.W, pady=2) + self.is_software_entry = ttk.Entry(fields_frame, width=55) + self.is_software_entry.grid(row=2, column=1, padx=10, sticky=tk.EW) + + # Чекбоксы + chk_frame = ttk.Frame(fields_frame) + chk_frame.grid(row=3, column=1, sticky=tk.W, pady=5) + 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=4, column=0, sticky=tk.W, pady=2) + threat_frame = ttk.Frame(fields_frame) + threat_frame.grid(row=4, column=1, sticky=tk.W, padx=10) + for val, text in [("1", "1 (НДВ в СПО)"), ("2", "2 (НДВ в ППО)"), ("3", "3 (НДВ нет)")]: + ttk.Radiobutton(threat_frame, text=text, variable=self.is_threat_var, + value=val).pack(side=tk.LEFT, padx=3) + + # Уровень защищённости + self.is_defence_var = tk.StringVar(value="4") + ttk.Label(fields_frame, text="Ур. защищённости:").grid(row=5, column=0, sticky=tk.W, pady=2) + def_frame = ttk.Frame(fields_frame) + def_frame.grid(row=5, 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=6, column=0, sticky=tk.NW, pady=2) + cat_frame = ttk.Frame(fields_frame) + cat_frame.grid(row=6, column=1, sticky=tk.W, padx=10) + self.category_vars = [] + for cat in ["специальные", "биометрические", "общедоступные", "иные"]: + var = tk.BooleanVar() + cb = ttk.Checkbutton(cat_frame, text=cat, variable=var) + cb.pack(anchor=tk.W) + self.category_vars.append((cat, var)) + + 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="Перечень ПДн") + self.pd_listbox = tk.Listbox(pd_tab, height=6) + self.pd_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + ttk.Button(pd_tab, text="+ Добавить", command=self._add_pd_item).pack(pady=3) + + # Субъекты ПДн + subj_tab = ttk.Frame(nb2) + nb2.add(subj_tab, text="Субъекты ПДн") + self.pd_subjects_listbox = tk.Listbox(subj_tab, height=6) + self.pd_subjects_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + ttk.Button(subj_tab, text="+ Добавить", command=self._add_pd_subjects_item).pack(pady=3) + + # СЗИ + def_tab = ttk.Frame(nb2) + nb2.add(def_tab, text="Средства защиты") + self.defense_tools_listbox = tk.Listbox(def_tab, height=6) + self.defense_tools_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + ttk.Button(def_tab, text="+ Добавить", command=self._add_defense_item).pack(pady=3) + + # Пользователи + 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_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.software = self.is_software_entry.get().strip() + isys.is_local_network = self.is_lan_var.get() + isys.is_internet = self.is_internet_var.get() + isys.threat_type = self.is_threat_var.get() + isys.defence_level = self.is_defence_var.get() + cats = [c for c, v in self.category_vars if v.get()] + isys.personal_data_category = cats + # Списки + 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.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() + + # Шаблоны + self._on_templates_changed() + + # ==================== КОМИССИЯ ==================== + 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' + for w in [self.is_name_entry, self.is_description_entry, self.is_software_entry]: + w.config(state=state) + self.is_lan_var.set(False) + self.is_internet_var.set(False) + + 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_software_entry.delete(0, tk.END) + self.is_software_entry.insert(0, isys.software or "") + self.is_lan_var.set(isys.is_local_network) + self.is_internet_var.set(isys.is_internet) + self.is_threat_var.set(isys.threat_type or "3") + self.is_defence_var.set(isys.defence_level or "4") + for cat, var in self.category_vars: + var.set(cat in isys.personal_data_category) + + # Списки + for lb, items in [ + (self.pd_listbox, isys.personal_data_list or []), + (self.pd_subjects_listbox, isys.pd_subjects_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): + 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, + software=src.software, + is_local_network=src.is_local_network, + is_internet=src.is_internet, + defence_level=src.defence_level, + threat_type=src.threat_type, + personal_data_category=list(src.personal_data_category), + personal_data_list=list(src.personal_data_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 + 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 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() + + # Проверка + if not self._validate_before_generate(): + return + + os.makedirs(output_dir, exist_ok=True) + + # Запускаем в отдельном потоке + 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, lambda: messagebox.showinfo( + "Готово", f"Сгенерировано {len(results)} документов\n" + f"Папка: {output_dir}" + )) + except Exception as e: + self.log(f"❌ Ошибка: {e}") + 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']) + if data.get('shortName'): + self.short_name_entry.delete(0, tk.END) + self.short_name_entry.insert(0, data['shortName']) + for key, entry in self._company_entries.items(): + val = data.get(key) or data.get(key.replace('_', '')) or '' + if val: + entry.delete(0, tk.END) + entry.insert(0, val) + for attr, entry in self._official_entries.items(): + val = data.get(attr) or '' + if val: + entry.delete(0, tk.END) + entry.insert(0, val) + 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() + 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() + 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..45af1fb --- /dev/null +++ b/dokogen/variables.py @@ -0,0 +1,327 @@ +# -*- 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'), +] + +# Маппинг старых переменных <...> → {{...}} +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 '—', + 'is_software_list': isys.software 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 '—', + 'software': isys.software or '—', + 'is_has_lan': 'Да' if isys.is_local_network else 'Нет', + 'has_internet': 'да' if isys.is_internet else 'нет', + 'defence_level': str(isys.defence_level or '—'), + '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) + + 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