From 7eb736bcbf1662680f8e083b7b1d2e920d6549f6 Mon Sep 17 00:00:00 2001 From: DokoGen Date: Thu, 30 Jul 2026 14:06:19 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B2=D1=81=D0=B5=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20UI,=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=91=D1=80,=20=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D0=B8,=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен dokogen/importer.py (импорт из Excel) - Созданы словари: ПДн, Субъекты, СЗИ (JSON) - Добавлена вкладка Пользователи (Должность, ФИО, доступ) - Исправлены: пустые поля номера/даты договора - Починен IS tab (названия не пропадают) - Кнопки Выбрать из списка для ПДн и СЗИ - Исправлен _apply_imported_data --- .gitignore | 1 - dokogen/__init__.py | 3 + dokogen/dictionaries/defense_tools.json | 14 + dokogen/dictionaries/pd_categories.json | 8 + dokogen/dictionaries/pd_items.json | 28 ++ dokogen/importer.py | 436 ++++++++++++++++++++++++ dokogen/models.py | 3 + dokogen/ui.py | 347 ++++++++++++++++++- 8 files changed, 830 insertions(+), 10 deletions(-) create mode 100644 dokogen/dictionaries/defense_tools.json create mode 100644 dokogen/dictionaries/pd_categories.json create mode 100644 dokogen/dictionaries/pd_items.json create mode 100644 dokogen/importer.py diff --git a/.gitignore b/.gitignore index 8808c86..01aac8c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ __pycache__/ .autosave/ Сгенерированные_документы/ *.result.docx - diff --git a/dokogen/__init__.py b/dokogen/__init__.py index cb75f24..a55ba29 100644 --- a/dokogen/__init__.py +++ b/dokogen/__init__.py @@ -6,5 +6,8 @@ from .declension import inflect_name, decline, company_name_decline, get_short_f 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/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_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/importer.py b/dokogen/importer.py new file mode 100644 index 0000000..f5cc115 --- /dev/null +++ b/dokogen/importer.py @@ -0,0 +1,436 @@ +# -*- 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': '', + 'software': '', '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 and 'перечень' not in field_lower): + is_obj['software'] = val_b + elif 'значение' in field_lower or 'примечание' in field_lower: + continue + elif val_b and 'выбрать' not in field_lower: + 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': + if field and 'выбрать' not in field_lower and field_lower not in ('значение', 'примечание'): + is_obj['description'] = field + log_fn(f" Цель: {field[:80]}") + elif val_b and 'выбрать' not in field_lower: + is_obj['description'] = val_b + 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']}") + + if is_obj['software'] and not is_obj['description']: + is_obj['description'] = f"Используемое ПО: {is_obj['software']}" + + # Автоопределение категорий ПДн + pd_cats = [] + all_pd_text = ' '.join(p.lower() for p in pd_items_raw) + 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 index e47cbe1..18ebe6c 100644 --- a/dokogen/models.py +++ b/dokogen/models.py @@ -89,6 +89,7 @@ class Company: 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): """Обновить склонения полного названия""" @@ -133,6 +134,8 @@ class Company: 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}) diff --git a/dokogen/ui.py b/dokogen/ui.py index 65a85b4..98f27e8 100644 --- a/dokogen/ui.py +++ b/dokogen/ui.py @@ -39,8 +39,8 @@ class DokoGenApp: ) # Переменные - self.doc_number_var = tk.StringVar(value="001") - self.doc_date_var = tk.StringVar(value=datetime.now().strftime("%d.%m.%Y")) + 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="Готов") @@ -110,6 +110,7 @@ class DokoGenApp: self._build_company_tab() self._build_is_tab() self._build_commission_tab() + self._build_users_tab() self._build_generation_tab() self._build_log_tab() @@ -327,23 +328,32 @@ class DokoGenApp: # Список ПДн 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=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) - ttk.Button(pd_tab, text="+ Добавить", command=self._add_pd_item).pack(pady=3) # Субъекты ПДн 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=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) - ttk.Button(subj_tab, text="+ Добавить", command=self._add_pd_subjects_item).pack(pady=3) # СЗИ 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=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) - ttk.Button(def_tab, text="+ Добавить", command=self._add_defense_item).pack(pady=3) # Пользователи user_tab = ttk.Frame(nb2) @@ -390,6 +400,103 @@ class DokoGenApp: 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="Пользователи") + + 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)) + + # Заголовки + header = ttk.Frame(frame) + header.pack(fill=tk.X) + for w, col in [(25, "Должность"), (30, "ФИО"), (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', ''), + emp.get('is_list', ''), + ) + self._update_emp_count() + + def _add_employee_row(self, position="", fio="", is_list=""): + """Добавляет одну строку сотрудника.""" + 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) + + is_entry = ttk.Entry(row, width=30) + is_entry.insert(0, is_list if isinstance(is_list, str) else ', '.join(is_list)) + is_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, is_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, is_e in self._employee_entries: + emp = { + 'position': pos_e.get().strip(), + 'fio': fio_e.get().strip(), + 'is_list': [s.strip() for s in is_e.get().split(',') if s.strip()], + } + if emp['position'] or emp['fio']: + self.model.employees_access.append(emp) + # ---------- ВКЛАДКА "ГЕНЕРАЦИЯ" ---------- def _build_generation_tab(self): tab = ttk.Frame(self.notebook) @@ -534,6 +641,19 @@ class DokoGenApp: 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', ''), + emp.get('is_list', []), + ) + self._update_emp_count() + # Шаблоны self._on_templates_changed() @@ -627,6 +747,8 @@ class DokoGenApp: lb.insert(tk.END, item) def _add_is(self): + # Сохраняем текущую ИС перед добавлением новой + self._collect_is() name = simpledialog.askstring("Добавить ИС", "Введите наименование ИС:") if name: isys = InformationSystem(name=name) @@ -662,6 +784,7 @@ class DokoGenApp: 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() @@ -694,6 +817,96 @@ class DokoGenApp: if sel: self.users_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 _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="Папка шаблонов") @@ -757,6 +970,7 @@ class DokoGenApp: self._collect_company() self._collect_commission() self._collect_is() + self._collect_employees() # Проверка if not self._validate_before_generate(): @@ -868,22 +1082,135 @@ class DokoGenApp: 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']) - for key, entry in self._company_entries.items(): - val = data.get(key) or data.get(key.replace('_', '')) or '' + 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) - for attr, entry in self._official_entries.items(): - val = data.get(attr) or '' + + # Должностные лица (маппинг) + 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', ''), + software=is_data.get('software', ''), + is_internet=is_data.get('isInternet', False), + pd_count=is_data.get('personalDataCount', 'менее 100 000'), + personal_data_list=is_data.get('personalDataList', []), + 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', ''), + 'is_list': emp.get('is_list', []), + }) + self._add_employee_row( + emp.get('position', ''), + emp.get('fio', ''), + emp.get('is_list', []), + ) + self._update_emp_count() + self.log("✅ Импорт применён") self.root.after(0, _apply) @@ -927,6 +1254,7 @@ class DokoGenApp: 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) @@ -951,6 +1279,7 @@ class DokoGenApp: 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: