Files
dokogen187/dokogen/ui.py
T

2241 lines
106 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""DokoGen — Пользовательский интерфейс (tkinter)"""
import tkinter as tk
from tkinter import ttk, messagebox, filedialog, simpledialog, scrolledtext
import os
import json
import sys
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,
)
from .verify import (
check_declension_morpher, check_company_dadata,
check_declension_ai, check_company_ai,
COMPANY_FIELDS, compare_company_values,
)
from .api_settings import load_api_settings, save_api_settings
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'
)
# Путь к файлу настроек импорта (словарь «что и откуда брать»)
# None = import_settings.json рядом с программой (создаётся автоматически)
self.import_settings_path: Optional[str] = None
# Файловый лог: рядом с exe (frozen) или рядом с модулем
if getattr(sys, 'frozen', False):
_base = os.path.dirname(sys.executable)
else:
_base = os.path.dirname(os.path.abspath(__file__))
self._log_path = os.path.join(_base, 'dokogen.log')
# Переменные
self.doc_number_var = tk.StringVar(value="")
self.doc_date_var = tk.StringVar(value="")
self.templates_var = tk.StringVar()
self.save_var = tk.StringVar()
self.status_var = tk.StringVar(value="Готов")
# Словари для хранения виджетов
self._company_entries: dict = {}
self._official_entries: dict = {}
self.commission_entries: list = []
self._build_menu()
self._build_ui()
self._enable_context_menu()
self._init_company()
# Привязка закрытия
root.protocol("WM_DELETE_WINDOW", self._on_close)
# Автосохранение
self._load_autosave()
self._start_autosave_timer()
# ==================== МЕНЮ ====================
def _build_menu(self):
menubar = tk.Menu(self.root)
self.root.config(menu=menubar)
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Файл", menu=file_menu)
file_menu.add_command(label="Новое", command=self.new_data, accelerator="Ctrl+N")
file_menu.add_command(label="Открыть", command=self.open_data, accelerator="Ctrl+O")
file_menu.add_command(label="Сохранить", command=self.save_data, accelerator="Ctrl+S")
file_menu.add_command(label="Сохранить как", command=self.save_as_data, accelerator="Ctrl+Shift+S")
file_menu.add_separator()
self.recent_menu = tk.Menu(file_menu, tearoff=0)
file_menu.add_cascade(label="Последние файлы", menu=self.recent_menu)
file_menu.add_separator()
file_menu.add_command(label="Настройки…", command=self.open_api_settings)
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_command(label="Файл настроек импорта…", command=self.select_import_settings)
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="Инструкция пользователя (Word)…", command=self.download_user_manual)
help_menu.add_command(label="Инструкция администратора (Word)…", command=self.download_admin_manual)
help_menu.add_command(label="Переменные шаблонов (Word)…", command=self.download_variables_doc)
help_menu.add_command(label="Пустой опросник (Excel)…", command=self.download_survey)
# ==================== ИНТЕРФЕЙС ====================
def _build_ui(self):
# Панель инструментов
toolbar = ttk.Frame(self.root)
toolbar.pack(side=tk.TOP, fill=tk.X, padx=5, pady=3)
ttk.Button(toolbar, text="📂 Открыть", command=self.open_data).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="💾 Сохранить", command=self.save_data).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="📥 Импорт", command=self.import_from_survey).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="📁 Шаблоны", command=self.select_templates).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="⚡ Генерация", command=self.generate_all).pack(side=tk.LEFT, padx=2)
# Ноутбук (вкладки)
self.notebook = ttk.Notebook(self.root)
self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=2)
self._build_company_tab()
self._build_is_tab()
self._build_commission_tab()
self._build_users_tab()
self._build_paper_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("<Control-n>", lambda e: self.new_data())
self.root.bind("<Control-o>", lambda e: self.open_data())
self.root.bind("<Control-s>", lambda e: self.save_data())
self.root.bind("<Control-S>", 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("<Configure>",
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)
left = ttk.Frame(main)
left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# --- Название организации ---
f1 = ttk.LabelFrame(left, 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(left, text="Адрес и реквизиты", padding=10)
f2.pack(fill=tk.X, pady=5)
addr_fields = [
("Юридический адрес:", "address"),
("Город:", "city_name"),
("ИНН:", "inn"),
("КПП:", "kpp"),
("ОГРН:", "ogrn"),
("Дата ОГРН:", "ogrn_date"),
]
for i, (label, key) in enumerate(addr_fields):
ttk.Label(f2, text=label).grid(row=i, column=0, sticky=tk.W, pady=2)
entry = ttk.Entry(f2, width=50)
entry.grid(row=i, column=1, padx=10, sticky=tk.EW, pady=1)
self._company_entries[key] = entry
# Привязка автозаполнения города из адреса
addr_entry = self._company_entries.get('address')
if addr_entry:
addr_entry.bind('<KeyRelease>', self._on_address_changed)
f2.columnconfigure(1, weight=1)
# --- Должностные лица ---
f3 = ttk.LabelFrame(left, 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(left, 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)
# --- Правая панель: проверка данных ---
right = ttk.LabelFrame(main, text="Проверка данных", padding=10)
right.pack(side=tk.RIGHT, fill=tk.Y, padx=(10, 0))
ttk.Label(right, text="Склонение названия:", font=('', 9, 'bold')).pack(anchor=tk.W)
ttk.Button(right, text="🌐 Склонение — Морфер 3.0",
command=self._check_declension_web).pack(fill=tk.X, pady=2)
ttk.Button(right, text="🤖 Склонение — ИИ",
command=self._check_declension_ai).pack(fill=tk.X, pady=2)
ttk.Label(right, text="Реквизиты организации:", font=('', 9, 'bold')).pack(anchor=tk.W, pady=(10, 0))
ttk.Button(right, text="🌐 Реквизиты — DaData",
command=self._check_company_web).pack(fill=tk.X, pady=2)
ttk.Button(right, text="🤖 Реквизиты — ИИ",
command=self._check_company_ai).pack(fill=tk.X, pady=2)
ttk.Label(right, text="Результат проверки:", font=('', 9, 'bold')).pack(anchor=tk.W, pady=(10, 0))
self._verify_text = scrolledtext.ScrolledText(
right, width=44, height=14, state=tk.DISABLED, wrap=tk.WORD, font=('', 9))
self._verify_text.pack(fill=tk.BOTH, expand=True, pady=4)
btn_row = ttk.Frame(right)
btn_row.pack(fill=tk.X)
ttk.Button(btn_row, text="✓ Применить", command=self._verify_apply).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="Очистить", command=self._verify_clear).pack(side=tk.LEFT, padx=2)
# Выборочное применение: чекбоксы «что подставлять»
self._apply_flags = {
'declension': tk.BooleanVar(value=True),
'short_declension': tk.BooleanVar(value=True),
'address': tk.BooleanVar(value=True),
'requisites': tk.BooleanVar(value=True),
'chief': tk.BooleanVar(value=True),
}
apply_row = ttk.Frame(right)
apply_row.pack(fill=tk.X, pady=(4, 0))
ttk.Label(apply_row, text="Подставлять:", font=('', 8)).pack(side=tk.LEFT, padx=(0, 4))
ttk.Checkbutton(apply_row, text="склонения", variable=self._apply_flags['declension']).pack(side=tk.LEFT)
ttk.Checkbutton(apply_row, text="кр. склон.", variable=self._apply_flags['short_declension']).pack(side=tk.LEFT)
ttk.Checkbutton(apply_row, text="адрес", variable=self._apply_flags['address']).pack(side=tk.LEFT)
ttk.Checkbutton(apply_row, text="ИНН/КПП/ОГРН", variable=self._apply_flags['requisites']).pack(side=tk.LEFT)
ttk.Checkbutton(apply_row, text="руковод.", variable=self._apply_flags['chief']).pack(side=tk.LEFT)
self._verify_result = None
# ---------- ПРОВЕРКА ДАННЫХ (Морфер / DaData / ИИ) ----------
def open_api_settings(self):
"""Окно настроек API-ключей (меню «Файл → Настройки»)."""
win = tk.Toplevel(self.root)
win.title("Настройки")
win.geometry("540x460")
win.transient(self.root)
win.grab_set()
nb = ttk.Notebook(win)
nb.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Вкладка API-ключи
api_tab = ttk.Frame(nb, padding=10)
nb.add(api_tab, text="API-ключи")
settings = load_api_settings()
ttk.Label(api_tab, text="DaData — проверка организации по ИНН/ОГРН",
font=('', 9, 'bold')).pack(anchor=tk.W)
ttk.Label(api_tab, text="API-ключ (получить на https://dadata.ru):").pack(anchor=tk.W, pady=(6, 0))
dadata_var = tk.StringVar(value=settings.get('dadata_token', ''))
ttk.Entry(api_tab, textvariable=dadata_var, width=64).pack(fill=tk.X, pady=2)
ttk.Label(api_tab, text="ИИ-проверка (OpenAI-совместимый API: DeepSeek, ChatGPT и др.)",
font=('', 9, 'bold')).pack(anchor=tk.W, pady=(14, 0))
ttk.Label(api_tab, text="API-ключ:").pack(anchor=tk.W, pady=(6, 0))
ds_var = tk.StringVar(value=settings.get('ai_api_key', ''))
ttk.Entry(api_tab, textvariable=ds_var, width=64).pack(fill=tk.X, pady=2)
ttk.Label(api_tab, text="Модель (например: deepseek-chat, gpt-4o-mini, gpt-4o):").pack(anchor=tk.W, pady=(6, 0))
model_var = tk.StringVar(value=settings.get('ai_model', 'deepseek-chat'))
ttk.Entry(api_tab, textvariable=model_var, width=64).pack(fill=tk.X, pady=2)
ttk.Label(api_tab, text="Базовый URL API (для DeepSeek можно оставить пустым):").pack(anchor=tk.W, pady=(6, 0))
url_var = tk.StringVar(value=settings.get('ai_base_url', ''))
ttk.Entry(api_tab, textvariable=url_var, width=64).pack(fill=tk.X, pady=2)
ttk.Label(api_tab, text="Морфер 3.0 работает без ключа (бесплатный склонятор).",
foreground='gray').pack(anchor=tk.W, pady=(14, 0))
def _save():
save_api_settings({
'dadata_token': dadata_var.get().strip(),
'ai_api_key': ds_var.get().strip(),
'ai_model': model_var.get().strip() or 'deepseek-chat',
'ai_base_url': url_var.get().strip(),
})
self.log("✅ Настройки API сохранены")
win.destroy()
ttk.Button(api_tab, text="💾 Сохранить", command=_save).pack(anchor=tk.W, pady=12)
# ---------- ПРОВЕРКИ ----------
_CASE_TO_CODE = {'И': 'nomn', 'Р': 'gent', 'Д': 'datv', 'В': 'accs', 'Т': 'ablt', 'П': 'loct'}
def _verify_log(self, text):
self._verify_text.config(state=tk.NORMAL)
self._verify_text.delete('1.0', tk.END)
self._verify_text.insert('1.0', text)
self._verify_text.config(state=tk.DISABLED)
def _verify_clear(self):
self._verify_result = None
self._verify_log('')
def _check_declension_web(self):
name = self.company_name_entry.get().strip()
short = self.short_name_entry.get().strip()
if not name and not short:
messagebox.showwarning("Проверка", "Сначала введите полное или краткое название организации")
return
self.log(f"🌐 Запрос к Морфер 3.0: «{name or short}»")
self._verify_log("Запрос к Морфер 3.0…")
def _run():
# Склоняем и полное, и краткое название (если заполнено)
results = []
if name:
r1, e1 = check_declension_morpher(name)
if e1:
self.root.after(0, lambda: self._verify_log(f"❌ {e1}"))
return
results.append(('declension', r1))
if short:
r2, e2 = check_declension_morpher(short)
if e2:
self.root.after(0, lambda: self._verify_log(f"❌ Краткое: {e2}"))
return
results.append(('short_declension', r2))
self.root.after(0, lambda: self._verify_declension_done(results, None))
threading.Thread(target=_run, daemon=True).start()
def _check_declension_ai(self):
name = self.company_name_entry.get().strip()
short = self.short_name_entry.get().strip()
if not name and not short:
messagebox.showwarning("Проверка", "Сначала введите полное или краткое название организации")
return
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
self.log(f"🤖 Запрос к ИИ ({settings.get('ai_model') or 'deepseek-chat'}): склонение «{name or short}»")
self._verify_log("Запрос к ИИ…")
def _run():
results = []
if name:
r1, e1 = check_declension_ai(name, settings['ai_api_key'],
settings.get('ai_model'), settings.get('ai_base_url'))
if e1:
self.root.after(0, lambda: self._verify_log(f"❌ {e1}"))
return
results.append(('declension', r1))
if short:
r2, e2 = check_declension_ai(short, settings['ai_api_key'],
settings.get('ai_model'), settings.get('ai_base_url'))
if e2:
self.root.after(0, lambda: self._verify_log(f"❌ Краткое: {e2}"))
return
results.append(('short_declension', r2))
self.root.after(0, lambda: self._verify_declension_done(results, None))
threading.Thread(target=_run, daemon=True).start()
def _verify_declension_done(self, results, err):
if err:
self._verify_log(f"❌ {err}")
self.log(f"❌ Проверка склонений: {err}")
return
# Собираем единый результат для применения
combined = {
'source': results[0][1]['source'] if results else 'ИИ',
'source_url': results[0][1]['source_url'] if results else '',
'disclaimer': results[0][1].get('disclaimer', '') if results else '',
'kind': 'declension',
'data': {},
'apply': {},
}
lines = [f"Источник: {combined['source']} ({combined['source_url']})"]
if combined['disclaimer']:
lines.append(combined['disclaimer'])
for key, res in results:
label = "Полное название" if key == 'declension' else "Краткое название"
lines.append(f"\n{label}:")
for code, name_case in [('И', 'Именительный'), ('Р', 'Родительный'), ('Д', 'Дательный'), ('В', 'Винительный'),
('Т', 'Творительный'), ('П', 'Предложный')]:
if code in res['data']:
lines.append(f" {name_case}: {res['data'][code]}")
if res.get('comment'):
lines.append(f" Комментарий: {res['comment']}")
combined['apply'][key] = res['apply']['declension']
lines.append("\nНажмите «✓ Применить», чтобы подставить склонения")
self._verify_result = combined
self._verify_log('\n'.join(lines))
# Общий лог с ответом сервиса
sample = ''
for key, res in results:
sample = res['data'].get('П', '')
if sample:
break
self.log(f"{combined['source']}: склонение проверено → «{sample}»")
def _check_company_web(self):
inn_entry = self._company_entries.get('inn')
inn = inn_entry.get().strip() if inn_entry else ''
if not inn:
messagebox.showwarning("Проверка", "Сначала введите ИНН организации")
return
settings = load_api_settings()
if not settings.get('dadata_token'):
messagebox.showwarning("Настройки", "Не задан API-ключ DaData.\nМеню «Файл → Настройки»")
return
self.log(f"🌐 Запрос к DaData: ИНН/ОГРН {inn}")
self._verify_log("Запрос к DaData…")
def _run():
result, err = check_company_dadata(inn, settings['dadata_token'])
self.root.after(0, lambda: self._verify_company_done(result, err))
threading.Thread(target=_run, daemon=True).start()
def _check_company_ai(self):
self._collect_company()
c = self.model
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
company_data = {
'full_name': c.full_name, 'short_name': c.short_name,
'address': c.address, 'inn': c.inn, 'kpp': c.kpp, 'ogrn': c.ogrn,
'ogrn_date': c.ogrn_date,
'chief_position': c.chief_position, 'chief_fio': c.chief_fio,
}
self.log(f"🤖 Запрос к ИИ ({settings.get('ai_model') or 'deepseek-chat'}): реквизиты (ИНН {company_data.get('inn') or '—'})")
self._verify_log("Запрос к ИИ…")
def _run():
result, err = check_company_ai(company_data, settings['ai_api_key'],
settings.get('ai_model'), settings.get('ai_base_url'))
self.root.after(0, lambda: self._verify_company_done(result, err))
threading.Thread(target=_run, daemon=True).start()
def _verify_company_done(self, result, err):
if err:
self._verify_log(f"❌ {err}")
self.log(f"❌ Проверка реквизитов: {err}")
return
self._verify_result = result
lines = [f"Источник: {result['source']} ({result['source_url']})"]
if result.get('disclaimer'):
lines.append(result['disclaimer'])
d = result['data']
if 'full_name' in d:
# DaData — фактические данные
for key, label in [('full_name', 'Наименование'), ('short_name', 'Краткое'),
('address', 'Адрес'), ('inn', 'ИНН'), ('kpp', 'КПП'),
('ogrn', 'ОГРН'), ('ogrn_date', 'Дата ОГРН'),
('chief_position', 'Руководитель'), ('chief_fio', 'ФИО')]:
if d.get(key):
lines.append(f"{label}: {d[key]}")
else:
# ИИ-рецензия: показываем снимок полей (что ИИ увидел), замечания и предложения
lines.append(f"Оценка: {d.get('rating', '')}")
fields = d.get('fields') or {}
if fields:
lines.append("\nПроверенные поля:")
for key, label in COMPANY_FIELDS:
info = fields.get(key)
if isinstance(info, dict):
status = info.get('статус', '')
comment = info.get('комментарий', '')
lines.append(f" {label}: {status}" + (f" — {comment}" if comment else ""))
for n in (d.get('notes') or []):
lines.append(f"• {n}")
props = d.get('proposals') or {}
if any(props.values()):
lines.append("\nПредложения ИИ:")
for key, label in COMPANY_FIELDS:
v = props.get(key)
if v:
lines.append(f" {label}: {v}")
lines.append("\nНажмите «✓ Применить», чтобы подставить данные")
self._verify_log('\n'.join(lines))
# Сравнение полученных значений с введёнными (серьёзные расхождения — красным)
current = {
'full_name': self.model.full_name,
'short_name': self.model.short_name,
'address': self.model.address,
'inn': self.model.inn,
'kpp': self.model.kpp,
'ogrn': self.model.ogrn,
'ogrn_date': self.model.ogrn_date,
'chief_position': self.model.chief_position,
'chief_fio': self.model.chief_fio,
}
if 'full_name' in d:
new_vals = d # DaData — фактические данные
else:
new_vals = d.get('proposals') or {} # ИИ — предложения
diffs = compare_company_values(current, new_vals)
if diffs:
warn = "⚠ ВНИМАНИЕ! НАЙДЕНЫ РАСХОЖДЕНИЯ:\n"
for label, old_v, new_v in diffs:
warn += f"• {label}: было «{old_v}», стало «{new_v}»\n"
self._verify_text.tag_configure('warn', foreground='red',
font=('Arial', 14, 'bold'))
self._verify_text.config(state=tk.NORMAL)
self._verify_text.insert(tk.END, '\n' + warn, 'warn')
self._verify_text.config(state=tk.DISABLED)
self.log(f"⚠ ВНИМАНИЕ: найдены расхождения ({len(diffs)}): " +
'; '.join(f"{l}: {o}{n}" for l, o, n in diffs[:3]))
# Общий лог с ответом сервиса
summary = ''
if 'full_name' in d:
summary = f"{d.get('full_name', '')[:60]} (ИНН {d.get('inn', '')})"
else:
summary = f"оценка {d.get('rating', '')}, замечаний: {len(d.get('notes') or [])}"
self.log(f"{result['source']}: проверка реквизитов → {summary}")
def _verify_apply(self):
result = self._verify_result
if not result:
return
apply_data = result.get('apply', {})
flags = getattr(self, '_apply_flags', None)
def want(key):
if flags is None:
return True
return flags[key].get() if key in flags else True
idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}
if 'declension' in apply_data and want('declension'):
for case, val in apply_data['declension'].items():
code = self._CASE_TO_CODE.get(case)
if code and code in self.decl_entries:
self.decl_entries[code].delete(0, tk.END)
self.decl_entries[code].insert(0, val)
setattr(self.model, f'company_name_{idx_map[code]}', val)
self.log("✅ Склонения полного названия применены")
if 'short_declension' in apply_data and want('short_declension'):
for case, val in apply_data['short_declension'].items():
code = self._CASE_TO_CODE.get(case)
if code and code in self.short_decl_entries:
self.short_decl_entries[code].delete(0, tk.END)
self.short_decl_entries[code].insert(0, val)
setattr(self.model, f'short_name_{idx_map[code]}', val)
self.log("✅ Склонения краткого названия применены")
if 'company' in apply_data:
comp = apply_data['company']
if comp.get('full_name'):
self.company_name_entry.delete(0, tk.END)
self.company_name_entry.insert(0, comp['full_name'])
if comp.get('short_name'):
self.short_name_entry.delete(0, tk.END)
self.short_name_entry.insert(0, comp['short_name'])
if want('address') and comp.get('address'):
if 'address' in self._company_entries:
self._company_entries['address'].delete(0, tk.END)
self._company_entries['address'].insert(0, comp['address'])
if want('requisites'):
for data_key, ui_key in {'kpp': 'kpp', 'ogrn': 'ogrn',
'ogrn_date': 'ogrn_date', 'inn': 'inn'}.items():
if comp.get(data_key) and ui_key in self._company_entries:
self._company_entries[ui_key].delete(0, tk.END)
self._company_entries[ui_key].insert(0, comp[data_key])
if want('chief'):
if comp.get('chief_position') and 'chief_position' in self._official_entries:
self._official_entries['chief_position'].delete(0, tk.END)
self._official_entries['chief_position'].insert(0, comp['chief_position'])
if comp.get('chief_fio') and 'chief_fio' in self._official_entries:
self._official_entries['chief_fio'].delete(0, tk.END)
self._official_entries['chief_fio'].insert(0, comp['chief_fio'])
self.log("✅ Реквизиты применены")
# ---------- ВКЛАДКА "ИНФОРМАЦИОННЫЕ СИСТЕМЫ" ----------
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('<<ListboxSelect>>', self._on_is_select)
btn_frame = ttk.Frame(left)
btn_frame.pack(fill=tk.X)
ttk.Button(btn_frame, text="+ Добавить", command=self._add_is).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="Копировать", command=self._copy_is).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="- Удалить", command=self._delete_is).pack(side=tk.LEFT, padx=2)
# Правая панель — редактирование
self._is_edit_panel = ttk.LabelFrame(tab, text="Редактирование ИС")
self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5)
fields_frame = ttk.Frame(self._is_edit_panel, padding=10)
fields_frame.pack(fill=tk.X)
# Основные поля
ttk.Label(fields_frame, text="Наименование:").grid(row=0, column=0, sticky=tk.W, pady=2)
self.is_name_entry = ttk.Entry(fields_frame, width=55)
self.is_name_entry.grid(row=0, column=1, padx=10, sticky=tk.EW)
ttk.Label(fields_frame, text="Описание:").grid(row=1, column=0, sticky=tk.W, pady=2)
self.is_description_entry = ttk.Entry(fields_frame, width=55)
self.is_description_entry.grid(row=1, column=1, padx=10, sticky=tk.EW)
# Чекбоксы
chk_frame = ttk.Frame(fields_frame)
chk_frame.grid(row=2, column=1, sticky=tk.W, pady=5)
self.is_lan_var = tk.BooleanVar()
ttk.Checkbutton(chk_frame, text="ЛВС", variable=self.is_lan_var).pack(side=tk.LEFT, padx=5)
self.is_internet_var = tk.BooleanVar()
ttk.Checkbutton(chk_frame, text="Интернет", variable=self.is_internet_var).pack(side=tk.LEFT, padx=5)
# Тип угроз
self.is_threat_var = tk.StringVar(value="3")
self.is_threat_var.trace_add("write", lambda *_: self._on_categories_or_count_changed())
ttk.Label(fields_frame, text="Тип угроз:").grid(row=3, column=0, sticky=tk.W, pady=2)
threat_frame = ttk.Frame(fields_frame)
threat_frame.grid(row=3, column=1, sticky=tk.W, padx=10)
for val, text in [("1", "1 (НДВ в СПО)"), ("2", "2 (НДВ в ППО)"), ("3", "3 (НДВ нет)")]:
ttk.Radiobutton(threat_frame, text=text, variable=self.is_threat_var,
value=val).pack(side=tk.LEFT, padx=3)
# Уровень защищённости
self.is_defence_var = tk.StringVar(value="4")
ttk.Label(fields_frame, text="Ур. защищённости:").grid(row=4, column=0, sticky=tk.W, pady=2)
def_frame = ttk.Frame(fields_frame)
def_frame.grid(row=4, column=1, sticky=tk.W, padx=10)
for lvl in ["1", "2", "3", "4"]:
ttk.Radiobutton(def_frame, text=f"{lvl} ур.",
variable=self.is_defence_var, value=lvl).pack(side=tk.LEFT, padx=3)
# Категории ПДн
ttk.Label(fields_frame, text="Категории ПДн:").grid(row=5, column=0, sticky=tk.NW, pady=2)
cat_frame = ttk.Frame(fields_frame)
cat_frame.grid(row=5, column=1, sticky=tk.W, padx=10)
self.category_vars = []
for cat in ["специальные", "биометрические", "общедоступные", "иные"]:
var = tk.BooleanVar()
var.trace_add('write', lambda *_: self._on_categories_or_count_changed())
cb = ttk.Checkbutton(cat_frame, text=cat, variable=var)
cb.pack(anchor=tk.W)
self.category_vars.append((cat, var))
# Количество записей ПДн (выпадающий список)
ttk.Label(fields_frame, text="Кол-во записей:").grid(row=6, column=0, sticky=tk.W, pady=2)
self.is_pd_count_var = tk.StringVar(value="менее 100 000")
self.is_pd_count_combo = ttk.Combobox(fields_frame, textvariable=self.is_pd_count_var,
values=["менее 100 000", "более 100 000"],
state='readonly', width=28)
self.is_pd_count_combo.grid(row=6, column=1, padx=10, sticky=tk.W)
self.is_pd_count_combo.bind('<<ComboboxSelected>>', lambda e: self._on_categories_or_count_changed())
# Структура ИС
ttk.Label(fields_frame, text="Структура ИС:").grid(row=7, column=0, sticky=tk.W, pady=2)
self.is_structure_entry = ttk.Entry(fields_frame, width=55)
self.is_structure_entry.grid(row=7, column=1, padx=10, sticky=tk.EW)
fields_frame.columnconfigure(1, weight=1)
# Вложенные списки
nb2 = ttk.Notebook(self._is_edit_panel)
nb2.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Список ПДн
pd_tab = ttk.Frame(nb2)
nb2.add(pd_tab, text="Перечень ПДн")
pd_btn_frame = ttk.Frame(pd_tab)
pd_btn_frame.pack(fill=tk.X)
ttk.Button(pd_btn_frame, text="+ Добавить", command=self._add_pd_item).pack(side=tk.LEFT, padx=2)
ttk.Button(pd_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_listbox)).pack(side=tk.LEFT, padx=2)
ttk.Button(pd_btn_frame, text="Выбрать из списка", command=self._select_from_pd_list).pack(side=tk.LEFT, padx=2)
self.pd_listbox = tk.Listbox(pd_tab, height=6)
self.pd_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Субъекты ПДн
subj_tab = ttk.Frame(nb2)
nb2.add(subj_tab, text="Субъекты ПДн")
subj_btn_frame = ttk.Frame(subj_tab)
subj_btn_frame.pack(fill=tk.X)
ttk.Button(subj_btn_frame, text="+ Добавить", command=self._add_pd_subjects_item).pack(side=tk.LEFT, padx=2)
ttk.Button(subj_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_subjects_listbox)).pack(side=tk.LEFT, padx=2)
ttk.Button(subj_btn_frame, text="Выбрать из списка", command=self._select_from_subjects_list).pack(side=tk.LEFT, padx=2)
self.pd_subjects_listbox = tk.Listbox(subj_tab, height=6)
self.pd_subjects_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Действия с ПДн
act_tab = ttk.Frame(nb2)
nb2.add(act_tab, text="Действия с ПДн")
act_btn_frame = ttk.Frame(act_tab)
act_btn_frame.pack(fill=tk.X)
ttk.Button(act_btn_frame, text="+ Добавить", command=self._add_pd_action_item).pack(side=tk.LEFT, padx=2)
ttk.Button(act_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_actions_listbox)).pack(side=tk.LEFT, padx=2)
ttk.Button(act_btn_frame, text="Выбрать из списка", command=self._select_from_actions_list).pack(side=tk.LEFT, padx=2)
self.pd_actions_listbox = tk.Listbox(act_tab, height=6)
self.pd_actions_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# СЗИ
def_tab = ttk.Frame(nb2)
nb2.add(def_tab, text="Средства защиты")
def_btn_frame = ttk.Frame(def_tab)
def_btn_frame.pack(fill=tk.X)
ttk.Button(def_btn_frame, text="+ Добавить", command=self._add_defense_item).pack(side=tk.LEFT, padx=2)
ttk.Button(def_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.defense_tools_listbox)).pack(side=tk.LEFT, padx=2)
ttk.Button(def_btn_frame, text="Выбрать из списка", command=self._select_from_defense_list).pack(side=tk.LEFT, padx=2)
self.defense_tools_listbox = tk.Listbox(def_tab, height=6)
self.defense_tools_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Пользователи
user_tab = ttk.Frame(nb2)
nb2.add(user_tab, text="Пользователи ИС")
self.users_listbox = tk.Listbox(user_tab, height=6)
self.users_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
btnf = ttk.Frame(user_tab)
btnf.pack(pady=3)
ttk.Button(btnf, text="👥 Выбрать из списка", command=self._select_users_from_employees).pack(side=tk.LEFT, padx=2)
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("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=inner, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
frame = ttk.Frame(inner, padding=10)
frame.pack(fill=tk.X)
ttk.Label(frame, text="Состав комиссии", font=('Arial', 11, 'bold')).pack(
anchor=tk.W, pady=(0, 10))
# Заголовки
header = ttk.Frame(frame)
header.pack(fill=tk.X)
for w, col in [(20, "Роль"), (30, "Должность"), (25, "ФИО")]:
ttk.Label(header, text=col, font=('', 9, 'bold'),
width=w, anchor=tk.W).pack(side=tk.LEFT, padx=2)
self._comm_members_frame = ttk.Frame(frame)
self._comm_members_frame.pack(fill=tk.X)
ttk.Button(frame, text="+ Добавить члена комиссии",
command=self._add_commission_row_ui).pack(pady=10)
# ---------- ВКЛАДКА "ПОЛЬЗОВАТЕЛИ" ----------
def _build_users_tab(self):
tab = ttk.Frame(self.notebook)
self.notebook.add(tab, text="Пользователи")
canvas = tk.Canvas(tab, highlightthickness=0)
scrollbar = ttk.Scrollbar(tab, orient=tk.VERTICAL, command=canvas.yview)
scrollable = ttk.Frame(canvas)
scrollable.bind("<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=scrollable, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
frame = ttk.Frame(scrollable, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text="Сотрудники, имеющие доступ к ПДн",
font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=(0, 10))
# Заголовки
header = ttk.Frame(frame)
header.pack(fill=tk.X)
for w, col in [(25, "Должность"), (30, "ФИО")]:
ttk.Label(header, text=col, font=('', 9, 'bold'),
width=w, anchor=tk.W).pack(side=tk.LEFT, padx=2)
self._employees_frame = ttk.Frame(frame)
self._employees_frame.pack(fill=tk.X, pady=5)
btn_frame = ttk.Frame(frame)
btn_frame.pack(fill=tk.X, pady=5)
ttk.Button(btn_frame, text="+ Добавить сотрудника",
command=self._add_employee_row_ui).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="- Удалить выбранного",
command=self._delete_employee_selected).pack(side=tk.LEFT, padx=2)
# Счётчик
self._emp_count_label = ttk.Label(frame, text="Добавлено: 0")
self._emp_count_label.pack(anchor=tk.W, pady=5)
self._employee_entries = []
# Загрузка существующих
for emp in self.model.employees_access:
self._add_employee_row(
emp.get('position', ''),
emp.get('fio', ''),
)
self._update_emp_count()
def _add_employee_row(self, position="", fio=""):
"""Добавляет одну строку сотрудника."""
row = ttk.Frame(self._employees_frame)
row.pack(fill=tk.X, pady=2)
pos_entry = ttk.Entry(row, width=25)
pos_entry.insert(0, position)
pos_entry.pack(side=tk.LEFT, padx=2)
fio_entry = ttk.Entry(row, width=30)
fio_entry.insert(0, fio)
fio_entry.pack(side=tk.LEFT, padx=2)
del_btn = ttk.Button(row, text="✖", width=3,
command=lambda: self._delete_employee_row(row))
del_btn.pack(side=tk.LEFT, padx=5)
self._employee_entries.append((row, pos_entry, fio_entry))
self._update_emp_count()
def _add_employee_row_ui(self):
self._add_employee_row()
def _delete_employee_row(self, row_frame):
self._employee_entries = [e for e in self._employee_entries if e[0] != row_frame]
row_frame.destroy()
self._update_emp_count()
def _delete_employee_selected(self):
# Удаляем последнюю строку, если есть
if self._employee_entries:
row_frame = self._employee_entries[-1][0]
self._delete_employee_row(row_frame)
def _update_emp_count(self):
count = len(self._employee_entries)
if hasattr(self, '_emp_count_label'):
self._emp_count_label.config(text=f"Добавлено: {count}")
def _collect_employees(self):
"""Собирает данные сотрудников из GUI в модель."""
self.model.employees_access.clear()
for row, pos_e, fio_e in self._employee_entries:
emp = {
'position': pos_e.get().strip(),
'fio': fio_e.get().strip(),
}
if emp['position'] or emp['fio']:
self.model.employees_access.append(emp)
# ---------- ВКЛАДКА "БУМАЖНЫЕ ДОКУМЕНТЫ" ----------
def _build_paper_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("<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=scrollable, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
frame = ttk.Frame(scrollable, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text="Бумажные носители ПДн (документы)",
font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=(0, 10))
# Заголовки
header = ttk.Frame(frame)
header.pack(fill=tk.X)
for w, col in [(45, "Название документа"), (45, "Место хранения")]:
ttk.Label(header, text=col, font=('', 9, 'bold'),
width=w, anchor=tk.W).pack(side=tk.LEFT, padx=2)
self._paper_frame = ttk.Frame(frame)
self._paper_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_paper_row_ui).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="- Удалить выбранный",
command=self._delete_paper_selected).pack(side=tk.LEFT, padx=2)
self._paper_count_label = ttk.Label(frame, text="Добавлено: 0")
self._paper_count_label.pack(anchor=tk.W, pady=5)
self._paper_entries = []
# Загрузка существующих
for p in self.model.paper_documents_list:
self._add_paper_row(p.name, p.storage)
self._update_paper_count()
def _add_paper_row(self, name="", storage=""):
"""Добавляет одну строку документа."""
row = ttk.Frame(self._paper_frame)
row.pack(fill=tk.X, pady=2)
name_entry = ttk.Entry(row, width=45)
name_entry.insert(0, name)
name_entry.pack(side=tk.LEFT, padx=2)
storage_entry = ttk.Entry(row, width=45)
storage_entry.insert(0, storage)
storage_entry.pack(side=tk.LEFT, padx=2)
del_btn = ttk.Button(row, text="✖", width=3,
command=lambda: self._delete_paper_row(row))
del_btn.pack(side=tk.LEFT, padx=5)
self._paper_entries.append((row, name_entry, storage_entry))
self._update_paper_count()
def _add_paper_row_ui(self):
self._add_paper_row()
def _delete_paper_row(self, row_frame):
self._paper_entries = [e for e in self._paper_entries if e[0] != row_frame]
row_frame.destroy()
self._update_paper_count()
def _delete_paper_selected(self):
if self._paper_entries:
row_frame = self._paper_entries[-1][0]
self._delete_paper_row(row_frame)
def _update_paper_count(self):
count = len(self._paper_entries)
if hasattr(self, '_paper_count_label'):
self._paper_count_label.config(text=f"Добавлено: {count}")
def _collect_paper_documents(self):
"""Собирает данные бумажных документов из GUI в модель."""
self.model.paper_documents_list.clear()
for row, name_e, storage_e in self._paper_entries:
name = name_e.get().strip()
storage = storage_e.get().strip()
if name or storage:
self.model.paper_documents_list.append(
PaperDocument(name=name, storage=storage)
)
# ---------- ВКЛАДКА "ГЕНЕРАЦИЯ" ----------
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('<<ListboxSelect>>', 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="Лог")
top = ttk.Frame(tab)
top.pack(fill=tk.X, padx=5, pady=3)
ttk.Button(top, text="💾 Экспорт логов…", command=self._export_logs).pack(side=tk.LEFT)
ttk.Label(top, text="Журнал пишется в dokogen.log рядом с программой",
foreground='gray').pack(side=tk.LEFT, padx=8)
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()
if hasattr(self, 'is_structure_entry'):
isys.structure = self.is_structure_entry.get().strip()
if hasattr(self, 'is_software_entry'):
isys.software = self.is_software_entry.get().strip()
isys.is_local_network = self.is_lan_var.get()
isys.is_internet = self.is_internet_var.get()
isys.threat_type = self.is_threat_var.get()
isys.defence_level = self.is_defence_var.get()
cats = [c for c, v in self.category_vars if v.get()]
isys.personal_data_category = cats
if hasattr(self, 'is_pd_count_entry'):
isys.pd_count = self.is_pd_count_entry.get().strip() or 'менее 100 000'
# Списки
isys.personal_data_list = list(self.pd_listbox.get(0, tk.END))
isys.pd_subjects_list = list(self.pd_subjects_listbox.get(0, tk.END))
isys.pd_actions_list = list(self.pd_actions_listbox.get(0, tk.END))
isys.defense_tools_list = list(self.defense_tools_listbox.get(0, tk.END))
isys.users_list = list(self.users_listbox.get(0, tk.END))
def _collect_commission(self):
"""Собирает комиссию из GUI."""
self.model.commission.clear()
for row_frame, role_var, pos_entry, fio_entry in self.commission_entries:
self.model.commission.append(
CommissionMember(
role=role_var.get(),
position=pos_entry.get().strip(),
fio=fio_entry.get().strip(),
)
)
def _company_to_gui(self):
"""Заполняет GUI из модели."""
c = self.model
self.company_name_entry.delete(0, tk.END)
self.company_name_entry.insert(0, c.full_name or "")
self.short_name_entry.delete(0, tk.END)
self.short_name_entry.insert(0, c.short_name or "")
for key, entry in self._company_entries.items():
entry.delete(0, tk.END)
entry.insert(0, getattr(c, key, "") or "")
for key, entry in self._official_entries.items():
entry.delete(0, tk.END)
entry.insert(0, getattr(c, key, "") or "")
self.doc_number_var.set(c.contract_number or "")
self.doc_date_var.set(c.contract_date or "")
# Склонения
idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}
for code, entry in self.decl_entries.items():
entry.delete(0, tk.END)
entry.insert(0, getattr(c, f"company_name_{idx_map[code]}", "") or "")
for code, entry in self.short_decl_entries.items():
entry.delete(0, tk.END)
entry.insert(0, getattr(c, f"short_name_{idx_map[code]}", "") or "")
# Комиссия
for frame in list(self._comm_members_frame.winfo_children()):
frame.destroy()
self.commission_entries.clear()
for member in c.commission:
self._add_commission_row(member.role, member.position, member.fio)
if not self.commission_entries:
for _ in range(3):
self._add_commission_row()
# ИС
self._refresh_is_listbox()
if self.model.information_systems:
self.is_listbox.selection_set(0)
self._on_is_select()
# Пользователи
if hasattr(self, '_employee_entries'):
for row_frame, _, _ in self._employee_entries:
row_frame.destroy()
self._employee_entries.clear()
for emp in c.employees_access:
self._add_employee_row(
emp.get('position', ''),
emp.get('fio', ''),
)
self._update_emp_count()
# Бумажные документы
if hasattr(self, '_paper_entries'):
for row_frame, _, _ in self._paper_entries:
row_frame.destroy()
self._paper_entries.clear()
for p in c.paper_documents_list:
self._add_paper_row(p.name, p.storage)
self._update_paper_count()
# Шаблоны
self._on_templates_changed()
# Автоопределение города из адреса после загрузки
self._on_address_changed()
# ==================== ГОРОД ИЗ АДРЕСА ====================
def _extract_city_from_address(self, address: str) -> str:
"""Извлекает название города из адреса (простая эвристика)."""
if not address:
return ''
# Пробуем найти "г. Город" или "Г Город" (без точки, заглавная)
import re
m = re.search(r'\b[гГ]\.?\s*([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address)
if m:
return m.group(1).strip()
# Пробуем найти "город Город"
m = re.search(r'\bгород\s+([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address, re.IGNORECASE)
if m:
return m.group(1).strip()
# Пробуем первую часть до запятой, если там известный город
parts = [p.strip() for p in address.split(',')]
if parts:
first = parts[0]
# Если первая часть — короткая и не похожа на улицу/дом
if len(first) <= 25 and first and not first.startswith(('ул', 'пр', 'пер', 'шоссе')):
# Проверяем, что это похоже на город
city_keywords = ['ск', 'цк', 'бург', 'град', 'полис', 'поль', 'стан', 'горск', 'чинск', 'янск', 'овск']
if any(first.lower().endswith(kw) for kw in city_keywords):
return first
return ''
def _on_address_changed(self, event=None):
"""Обработчик изменения адреса — автозаполняет город."""
addr_entry = self._company_entries.get('address')
city_entry = self._company_entries.get('city_name')
if not addr_entry or not city_entry:
return
address = addr_entry.get().strip()
if not address:
return
current_city = city_entry.get().strip()
if current_city:
return # Не перезаписываем, если уже заполнен
city = self._extract_city_from_address(address)
if city:
city_entry.delete(0, tk.END)
city_entry.insert(0, city)
self.log(f"🏙 Город определён: {city}")
# ==================== АВТОРАСЧЁТ УРОВНЯ ЗАЩИЩЁННОСТИ ====================
def _auto_calc_defence_level(self):
"""Автоматический расчёт УЗ по ПП №1119: категории ПДн, тип субъектов,
кол-во записей, тип угроз."""
if self._current_is_idx is None:
return
categories = [cat for cat, var in self.category_vars if var.get()]
pd_count = self.is_pd_count_var.get() if hasattr(self, 'is_pd_count_var') else 'менее 100 000'
threat_type = self.is_threat_var.get() if hasattr(self, 'is_threat_var') else '3'
is_large = 'более' in pd_count.lower()
# Автоопределение типа субъектов: если в Субъектах ПДн выбраны только "Сотрудники" → работники, иначе → иные
subj_items = self.pd_subjects_listbox.get(0, tk.END) if hasattr(self, 'pd_subjects_listbox') else []
has_only_employees = len(subj_items) > 0 and all('сотрудник' in s.lower() for s in subj_items)
is_employees = has_only_employees or len(subj_items) == 0
# Таблица ПП №1119: (категория, тип_субъектов, объём, тип_угроз) → УЗ
def lookup(cat_group, employees, large, threat):
# Общедоступные → всегда 4
if cat_group == 'public':
return 4
# Специальные/биометрические
if cat_group == 'special_bio':
tbl = {
('emp', False, '1'): 2, ('emp', True, '1'): 1,
('other', False, '1'): 1, ('other', True, '1'): 1,
('emp', False, '2'): 2, ('emp', True, '2'): 1,
('other', False, '2'): 2, ('other', True, '2'): 1,
('emp', False, '3'): 3, ('emp', True, '3'): 2,
('other', False, '3'): 3, ('other', True, '3'): 2,
}
else: # иные
tbl = {
('emp', False, '1'): 3, ('emp', True, '1'): 2,
('other', False, '1'): 2, ('other', True, '1'): 1,
('emp', False, '2'): 4, ('emp', True, '2'): 3,
('other', False, '2'): 3, ('other', True, '2'): 2,
('emp', False, '3'): 4, ('emp', True, '3'): 4,
('other', False, '3'): 4, ('other', True, '3'): 3,
}
key = ('emp' if employees else 'other', large, threat)
return tbl.get(key, 4)
# Определяем группу категорий
if 'общедоступные' in categories and not any(c in categories for c in ['специальные', 'биометрические', 'иные']):
cat_group = 'public'
elif any(c in categories for c in ['специальные', 'биометрические']):
cat_group = 'special_bio'
else:
cat_group = 'other'
level = lookup(cat_group, is_employees, is_large, threat_type)
self.is_defence_var.set(str(level))
def _on_categories_or_count_changed(self):
"""Обработчик изменения категорий ПДн или количества записей."""
if self._current_is_idx is not None:
self._auto_calc_defence_level()
# ==================== КОМИССИЯ ====================
def _add_commission_row(self, role="Член комиссии", position="", fio=""):
row = ttk.Frame(self._comm_members_frame)
row.pack(fill=tk.X, pady=2)
role_var = tk.StringVar(value=role)
role_cb = ttk.Combobox(row, textvariable=role_var, state='readonly', width=22,
values=["Председатель комиссии", "Секретарь комиссии",
"Член комиссии"])
role_cb.pack(side=tk.LEFT, padx=2)
pos_entry = ttk.Entry(row, width=30)
pos_entry.insert(0, position)
pos_entry.pack(side=tk.LEFT, padx=2)
fio_entry = ttk.Entry(row, width=25)
fio_entry.insert(0, fio)
fio_entry.pack(side=tk.LEFT, padx=2)
ttk.Button(row, text="✖", width=3,
command=lambda: self._delete_commission_row(row)).pack(side=tk.LEFT, padx=5)
self.commission_entries.append((row, role_var, pos_entry, fio_entry))
def _add_commission_row_ui(self):
self._add_commission_row()
def _delete_commission_row(self, row_frame):
self.commission_entries = [e for e in self.commission_entries if e[0] != row_frame]
row_frame.destroy()
# ==================== ИНФОРМАЦИОННЫЕ СИСТЕМЫ ====================
def _refresh_is_listbox(self):
self.is_listbox.delete(0, tk.END)
for isys in self.model.information_systems:
self.is_listbox.insert(tk.END, isys.name or "(без названия)")
if not self.model.information_systems:
self._is_edit_panel.pack_forget()
self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5)
self._set_is_edit_state(False)
else:
self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5)
def _set_is_edit_state(self, enabled):
state = 'normal' if enabled else 'disabled'
self.is_lan_var.set(False)
self.is_internet_var.set(False)
def _on_is_select(self, event=None):
sel = self.is_listbox.curselection()
if not sel:
self._current_is_idx = None
return
# Сохраняем текущую ИС
self._collect_is()
idx = sel[0]
if idx >= len(self.model.information_systems):
return
self._current_is_idx = idx
isys = self.model.information_systems[idx]
self.is_name_entry.delete(0, tk.END)
self.is_name_entry.insert(0, isys.name or "")
self.is_description_entry.delete(0, tk.END)
self.is_description_entry.insert(0, isys.description or "")
if hasattr(self, 'is_structure_entry'):
self.is_structure_entry.delete(0, tk.END)
self.is_structure_entry.insert(0, isys.structure or "")
if hasattr(self, 'is_software_entry'):
self.is_software_entry.delete(0, tk.END)
self.is_software_entry.insert(0, isys.software or "")
self.is_lan_var.set(isys.is_local_network)
self.is_internet_var.set(isys.is_internet)
self.is_threat_var.set(isys.threat_type or "3")
self.is_defence_var.set(isys.defence_level or "4")
for cat, var in self.category_vars:
var.set(cat in isys.personal_data_category)
# Заполняем кол-во записей ПДн
if hasattr(self, 'is_pd_count_entry'):
self.is_pd_count_entry.delete(0, tk.END)
self.is_pd_count_entry.insert(0, isys.pd_count or 'менее 100 000')
# Списки
for lb, items in [
(self.pd_listbox, isys.personal_data_list or []),
(self.pd_subjects_listbox, isys.pd_subjects_list or []),
(self.pd_actions_listbox, isys.pd_actions_list or []),
(self.defense_tools_listbox, isys.defense_tools_list or []),
(self.users_listbox, isys.users_list or []),
]:
lb.delete(0, tk.END)
for item in items:
lb.insert(tk.END, item)
def _add_is(self):
# Сохраняем текущую ИС перед добавлением новой
self._collect_is()
name = simpledialog.askstring("Добавить ИС", "Введите наименование ИС:")
if name:
isys = InformationSystem(name=name)
self.model.information_systems.append(isys)
self._refresh_is_listbox()
self.is_listbox.selection_set(tk.END)
self._on_is_select()
def _copy_is(self):
if self._current_is_idx is None:
return
self._collect_is()
src = self.model.information_systems[self._current_is_idx]
copy = InformationSystem(
name=src.name + " (копия)",
description=src.description,
is_local_network=src.is_local_network,
is_internet=src.is_internet,
defence_level=src.defence_level,
subject_type=src.subject_type,
threat_type=src.threat_type,
personal_data_category=list(src.personal_data_category),
personal_data_list=list(src.personal_data_list),
pd_actions_list=list(src.pd_actions_list),
pd_subjects_list=list(src.pd_subjects_list),
defense_tools_list=list(src.defense_tools_list),
users_list=list(src.users_list),
)
self.model.information_systems.append(copy)
self._refresh_is_listbox()
self.is_listbox.selection_set(tk.END)
self._on_is_select()
def _delete_is(self):
if self._current_is_idx is None:
return
self._collect_is()
del self.model.information_systems[self._current_is_idx]
self._current_is_idx = None
self._refresh_is_listbox()
if self.model.information_systems:
self.is_listbox.selection_set(0)
self._on_is_select()
def _add_pd_item(self):
item = simpledialog.askstring("Добавить ПДн", "Введите персональные данные:")
if item:
self.pd_listbox.insert(tk.END, item)
def _add_pd_subjects_item(self):
item = simpledialog.askstring("Добавить категорию", "Введите категорию субъектов:")
if item:
self.pd_subjects_listbox.insert(tk.END, item)
def _add_defense_item(self):
item = simpledialog.askstring("Добавить СЗИ", "Введите средство защиты:")
if item:
self.defense_tools_listbox.insert(tk.END, item)
def _add_user_to_is(self):
user = simpledialog.askstring("Добавить пользователя", "Введите ФИО или должность:")
if user:
self.users_listbox.insert(tk.END, user)
def _select_users_from_employees(self):
"""Выбор пользователей ИС из списка сотрудников организации (вкладка «Пользователи»)."""
names = []
for emp in self.model.employees_access:
fio = (emp.get('fio') or '').strip()
if fio and fio not in names:
names.append(fio)
# Дополнительно: пользователи из других ИС
for isys in self.model.information_systems:
for u in (isys.users_list or []):
u = u.strip()
if u and u not in names:
names.append(u)
if not names:
messagebox.showinfo(
"Выбор пользователей",
"Список пуст.\nЗаполните вкладку «Пользователи» (сотрудники с доступом к ПДн) "
"или добавьте пользователей в другой ИС."
)
return
self._select_from_dialog("Выбор пользователей ИС", names, self.users_listbox)
def _del_user_from_is(self):
sel = self.users_listbox.curselection()
if sel:
self.users_listbox.delete(sel[0])
def _del_from_listbox(self, listbox):
"""Удаление выбранного элемента из любого listbox."""
sel = listbox.curselection()
if sel:
listbox.delete(sel[0])
# ==================== ВЫБОР ИЗ СПИСКА (словари) ====================
def _select_from_dialog(self, title: str, items: list, listbox):
"""Открывает окно с чекбоксами для выбора элементов из списка."""
dialog = tk.Toplevel(self.root)
dialog.title(title)
dialog.geometry("400x500")
dialog.transient(self.root)
dialog.grab_set()
frame = ttk.Frame(dialog, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text=title, font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=(0, 5))
# Уже выбранные элементы
existing = set(listbox.get(0, tk.END))
canvas = tk.Canvas(frame, highlightthickness=0)
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas.yview)
check_frame = ttk.Frame(canvas)
check_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=check_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
vars = {}
for item in items:
var = tk.BooleanVar(value=item in existing)
cb = ttk.Checkbutton(check_frame, text=item, variable=var)
cb.pack(anchor=tk.W, pady=1)
vars[item] = var
btn_frame = ttk.Frame(dialog, padding=10)
btn_frame.pack(fill=tk.X)
def _apply():
selected = [item for item, var in vars.items() if var.get()]
listbox.delete(0, tk.END)
for item in selected:
listbox.insert(tk.END, item)
dialog.destroy()
def _select_all():
for var in vars.values():
var.set(True)
def _clear_all():
for var in vars.values():
var.set(False)
ttk.Button(btn_frame, text="Выбрать всё", command=_select_all).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="Снять всё", command=_clear_all).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="✅ Применить", command=_apply).pack(side=tk.RIGHT, padx=2)
dialog.wait_window()
def _add_pd_action_item(self):
item = simpledialog.askstring("Добавить действие", "Введите действие с ПДн:")
if item:
self.pd_actions_listbox.insert(tk.END, item)
def _select_from_actions_list(self):
"""Выбор действий с ПДн из JSON словаря."""
import json
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_actions.json')
try:
with open(dict_path, 'r', encoding='utf-8') as f:
items = json.load(f)
self._select_from_dialog("Выбор действий с персональными данными", items, self.pd_actions_listbox)
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
def _select_from_pd_list(self):
"""Выбор ПДн из JSON словаря."""
import json
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_items.json')
try:
with open(dict_path, 'r', encoding='utf-8') as f:
items = json.load(f)
self._select_from_dialog("Выбор персональных данных", items, self.pd_listbox)
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
def _select_from_subjects_list(self):
"""Выбор категорий субъектов из JSON словаря."""
import json
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_categories.json')
try:
with open(dict_path, 'r', encoding='utf-8') as f:
items = json.load(f)
self._select_from_dialog("Выбор категорий субъектов ПДн", items, self.pd_subjects_listbox)
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
def _select_from_defense_list(self):
"""Выбор СЗИ из JSON словаря."""
import json
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'defense_tools.json')
try:
with open(dict_path, 'r', encoding='utf-8') as f:
items = json.load(f)
self._select_from_dialog("Выбор средств защиты информации", items, self.defense_tools_listbox)
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
# ==================== ШАБЛОНЫ ====================
def select_templates(self):
folder = filedialog.askdirectory(title="Папка шаблонов")
if folder:
self.templates_var.set(folder)
self._on_templates_changed()
def _select_save_dir(self):
folder = filedialog.askdirectory(title="Папка для результатов")
if folder:
self.save_var.set(folder)
def _on_templates_changed(self, *_):
"""Обновить список шаблонов при смене папки."""
folder = self.templates_var.get()
self.template_listbox.delete(0, tk.END)
self._template_files = []
if folder and os.path.isdir(folder):
for f in sorted(os.listdir(folder)):
if f.endswith('.docx') and not f.endswith('_result.docx'):
self.template_listbox.insert(tk.END, f)
self._template_files.append(f)
# Выделяем все по умолчанию
self.template_listbox.selection_set(0, tk.END)
self._selected_templates = set(self._template_files)
self.log(f"Найдено шаблонов: {len(self._template_files)}")
def _on_template_select(self, event=None):
sel = self.template_listbox.curselection()
self._selected_templates = {self._template_files[i] for i in sel}
def _select_templates_all(self, select: bool):
if select:
self.template_listbox.selection_set(0, tk.END)
else:
self.template_listbox.selection_clear(0, tk.END)
self._on_template_select()
# ==================== ГЕНЕРАЦИЯ ====================
def generate_all(self):
"""Генерация всех выбранных документов."""
templates_dir = self.templates_var.get()
output_dir = self.save_var.get()
if not templates_dir or not os.path.isdir(templates_dir):
messagebox.showerror("Ошибка", "Выберите папку с шаблонами")
return
if not output_dir:
output_dir = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"Сгенерированные_документы"
)
self.save_var.set(output_dir)
selected = self._selected_templates
if not selected:
messagebox.showwarning("Предупреждение", "Не выбран ни один шаблон")
return
# Собираем данные
self._collect_company()
self._collect_commission()
self._collect_is()
self._collect_employees()
self._collect_paper_documents()
# Проверка
if not self._validate_before_generate():
return
os.makedirs(output_dir, exist_ok=True)
# Окно прогресса
progress_win = tk.Toplevel(self.root)
progress_win.title("Генерация документов")
progress_win.geometry("350x100")
progress_win.transient(self.root)
progress_win.grab_set()
progress_win.resizable(False, False)
ttk.Label(progress_win, text="Генерация документов...",
font=('Arial', 10)).pack(pady=(15, 5))
pb = ttk.Progressbar(progress_win, mode='indeterminate', length=300)
pb.pack(pady=5)
pb.start()
def _close_progress():
try:
pb.stop()
progress_win.grab_release()
progress_win.destroy()
except Exception:
pass
# Запускаем в отдельном потоке
def _run():
try:
replacements = build_replacements(
company=self.model,
is_list=self.model.information_systems,
doc_number=self.doc_number_var.get(),
doc_date=self.doc_date_var.get(),
direction='152fz',
)
results = []
for fname in selected:
template_path = os.path.join(templates_dir, fname)
if not os.path.isfile(template_path):
self.log(f"❌ Файл не найден: {fname}")
continue
base = os.path.splitext(fname)[0]
output_path = os.path.join(output_dir, f"{base}.docx")
try:
process_template(template_path, replacements, output_path, self.log)
results.append(output_path)
except Exception as e:
self.log(f"❌ {fname}: {e}")
self.log(f"✅ Готово: {len(results)} из {len(selected)}")
self.root.after(0, _close_progress)
self.root.after(0, lambda: messagebox.showinfo(
"Готово", f"Сгенерировано {len(results)} документов\n"
f"Папка: {output_dir}"
))
except Exception as e:
self.log(f"❌ Ошибка: {e}")
self.root.after(0, _close_progress)
self.root.after(0, lambda: messagebox.showerror("Ошибка", str(e)))
threading.Thread(target=_run, daemon=True).start()
def _validate_before_generate(self):
warnings = []
if not self.model.full_name:
warnings.append("Полное наименование организации")
if not self.model.inn:
warnings.append("ИНН")
if not self.model.chief_fio:
warnings.append("ФИО руководителя")
if not self.model.information_systems:
warnings.append("Нет информационных систем")
if warnings:
msg = "Не заполнены:\n" + "\n".join(warnings)
return messagebox.askyesno("Внимание", msg + "\n\nПродолжить?")
return True
# ==================== СКЛОНЕНИЕ ====================
def _update_company_declensions(self):
name = self.company_name_entry.get().strip()
if not name:
return
self.model.full_name = name
self.model.update_declensions(inflect_name)
idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}
for code, entry in self.decl_entries.items():
entry.delete(0, tk.END)
entry.insert(0, getattr(self.model, f"company_name_{idx_map[code]}", ""))
self.log("✅ Склонения полного названия обновлены")
def _update_short_declensions(self):
name = self.short_name_entry.get().strip()
if not name:
return
self.model.short_name = name
self.model.update_short_declensions(inflect_name)
idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}
for code, entry in self.short_decl_entries.items():
entry.delete(0, tk.END)
entry.insert(0, getattr(self.model, f"short_name_{idx_map[code]}", ""))
self.log("✅ Склонения краткого названия обновлены")
# ==================== ИМПОРТ ====================
def select_import_settings(self):
"""Выбор файла настроек импорта (JSON-словарь)."""
from .import_settings import default_import_settings_path, ensure_default_file
path = filedialog.askopenfilename(
title="Файл настроек импорта (что и откуда брать)",
filetypes=[("Настройки импорта", "*.json")]
)
if not path:
self.log(f"Настройки импорта: по умолчанию ({default_import_settings_path()})")
self.import_settings_path = None
return
self.import_settings_path = path
self.log(f"Настройки импорта: {path}")
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, settings_path=self.import_settings_path)
if data:
self._apply_imported_data(data)
except ImportError:
self.log("❌ Модуль импорта не найден")
except Exception as e:
self.log(f"❌ Ошибка импорта: {e}")
threading.Thread(target=_run, daemon=True).start()
def _apply_imported_data(self, data: dict):
def _apply():
# Название организации
if data.get('fullName'):
self.company_name_entry.delete(0, tk.END)
self.company_name_entry.insert(0, data['fullName'])
self.model.full_name = data['fullName']
if data.get('shortName'):
self.short_name_entry.delete(0, tk.END)
self.short_name_entry.insert(0, data['shortName'])
self.model.short_name = data['shortName']
# Адрес и реквизиты (маппинг camelCase → snake_case)
addr_map = {
'address': 'addressLegal',
'city_name': None,
'inn': 'inn',
'kpp': 'kpp',
'ogrn': 'ogrn',
'ogrn_date': 'ogrnDate',
}
for entry_key, data_key in addr_map.items():
entry = self._company_entries.get(entry_key)
if not entry:
continue
val = ''
if data_key and data.get(data_key):
val = data[data_key]
elif data.get(entry_key):
val = data[entry_key]
if val:
entry.delete(0, tk.END)
entry.insert(0, val)
# Автоопределение города из адреса после импорта
self._on_address_changed()
# Должностные лица (маппинг)
officials_map = {
'chief_position': 'chiefPosition',
'chief_fio': 'chiefFio',
'responsible_position': 'responsiblePosition',
'responsible_fio': 'responsibleFio',
'admin_position': ('administratorPosition', 'ispdnPosition'),
'admin_fio': ('administratorFio', 'ispdnFio'),
}
for entry_key, data_keys in officials_map.items():
entry = self._official_entries.get(entry_key)
if not entry:
continue
val = ''
if isinstance(data_keys, tuple):
for dk in data_keys:
if data.get(dk):
val = data[dk]
break
elif data.get(data_keys):
val = data[data_keys]
if val:
entry.delete(0, tk.END)
entry.insert(0, val)
# Номер и дата документа
if data.get('contractNumber'):
self.doc_number_var.set(data['contractNumber'])
if data.get('contractDate'):
self.doc_date_var.set(data['contractDate'])
# Наименование структурного подразделения по безопасности
if data.get('ispdnDepartment'):
self.model.security_department = data['ispdnDepartment']
# Информационные системы
if data.get('informationSystems'):
self.model.information_systems.clear()
for is_data in data['informationSystems']:
isys = InformationSystem(
name=is_data.get('name', ''),
description=is_data.get('description', ''),
is_local_network=is_data.get('is_has_lan', is_data.get('is_local_network', False)),
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', ''),
structure=is_data.get('structure', ''),
pd_actions_list=is_data.get('pd_actions', []),
)
self.model.information_systems.append(isys)
self._refresh_is_listbox()
if self.model.information_systems:
self.is_listbox.selection_set(0)
self._on_is_select()
# Комиссия
if data.get('commission'):
self.model.commission.clear()
# Очистим UI
for row_frame, _, _, _ in self.commission_entries:
row_frame.destroy()
self.commission_entries.clear()
for m in data['commission']:
self.model.commission.append(
CommissionMember(
role=m.get('role', 'Член комиссии'),
position=m.get('position', ''),
fio=m.get('fio', ''),
)
)
self._add_commission_row(
m.get('role', 'Член комиссии'),
m.get('position', ''),
m.get('fio', ''),
)
# Сотрудники с доступом
if data.get('employeesAccess'):
self.model.employees_access.clear()
if hasattr(self, '_employee_entries'):
for row_frame, _, _ in self._employee_entries:
row_frame.destroy()
self._employee_entries.clear()
for emp in data['employeesAccess']:
self.model.employees_access.append({
'position': emp.get('position', ''),
'fio': emp.get('fio', ''),
})
self._add_employee_row(
emp.get('position', ''),
emp.get('fio', ''),
)
self._update_emp_count()
# Бумажные документы (из «Названия документов» / «Место хранения»)
if data.get('paperDocuments'):
self.model.paper_documents_list.clear()
names = [n.strip() for n in data['paperDocuments'].replace('\n', ';').split(';') if n.strip()]
storages = [s.strip() for s in (data.get('paperStorage') or '').replace('\n', ';').split(';') if s.strip()]
for i, name in enumerate(names):
storage = storages[i] if i < len(storages) else (storages[0] if storages else '')
self.model.paper_documents_list.append(
PaperDocument(name=name, storage=storage)
)
# Обновляем GUI
if hasattr(self, '_paper_entries'):
for row_frame, _, _ in self._paper_entries:
row_frame.destroy()
self._paper_entries.clear()
for p in self.model.paper_documents_list:
self._add_paper_row(p.name, p.storage)
self._update_paper_count()
self.log(f" Бумажные документы: {len(self.model.paper_documents_list)} шт.")
self.log("✅ Импорт применён")
self.root.after(0, _apply)
# ==================== ФАЙЛОВЫЕ ОПЕРАЦИИ ====================
def new_data(self):
if self.model.full_name:
if not messagebox.askyesno("Новое", "Текущие данные будут потеряны. Продолжить?"):
return
self._init_company()
self.current_file = None
self._company_to_gui()
self.log("Новые данные")
def open_data(self):
filename = filedialog.askopenfilename(filetypes=[("JSON", "*.json")])
if filename:
try:
with open(filename, 'r', encoding='utf-8') as f:
data = json.load(f)
self.model = Company.from_dict(data)
self.current_file = filename
self._company_to_gui()
self.log(f"Загружено: {filename}")
except Exception as e:
messagebox.showerror("Ошибка", str(e))
def save_data(self):
if self.current_file:
self._save_to(self.current_file)
else:
self.save_as_data()
def save_as_data(self):
filename = filedialog.asksaveasfilename(
defaultextension=".json", filetypes=[("JSON", "*.json")]
)
if filename:
self._save_to(filename)
def _save_to(self, filename):
self._collect_company()
self._collect_commission()
self._collect_is()
self._collect_employees()
self._collect_paper_documents()
try:
with open(filename, 'w', encoding='utf-8') as f:
json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2)
self.current_file = filename
self.log(f"Сохранено: {filename}")
except Exception as e:
messagebox.showerror("Ошибка", str(e))
# ==================== АВТОСОХРАНЕНИЕ ====================
def _start_autosave_timer(self):
self._autosave()
self._autosave_timer_id = self.root.after(120000, self._start_autosave_timer)
def _stop_autosave_timer(self):
if hasattr(self, '_autosave_timer_id') and self._autosave_timer_id:
try:
self.root.after_cancel(self._autosave_timer_id)
except:
pass
def _autosave(self):
self._collect_company()
self._collect_commission()
self._collect_is()
self._collect_employees()
self._collect_paper_documents()
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('<Button-3>', _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
# Дублируем в файловый журнал
try:
with open(self._log_path, 'a', encoding='utf-8') as f:
f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}\n")
except Exception:
pass
self.status_var.set(message)
def _export_logs(self):
"""Экспорт лога в файл."""
filename = filedialog.asksaveasfilename(
title="Экспорт логов",
defaultextension=".log",
initialfile=f"dokogen_log_{datetime.now().strftime('%Y%m%d_%H%M')}.log",
filetypes=[("Лог-файл", "*.log"), ("Текстовый файл", "*.txt"), ("Все файлы", "*.*")]
)
if not filename:
return
try:
content = self.log_text.get('1.0', tk.END)
with open(filename, 'w', encoding='utf-8') as f:
f.write(content)
self.log(f"💾 Лог экспортирован: {filename}")
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось экспортировать лог: {e}")
def show_about(self):
messagebox.showinfo("О программе",
"ДоКоГеНеРаТоР — 152-ФЗ\n"
"Версия 1.0\n"
"Генератор документов из шаблонов")
def show_variables(self):
"""Показ переменных шаблонов — теперь через скачивание Word-документа."""
self.download_variables_doc()
# ---------- СПРАВКА: СКАЧИВАНИЕ ИНСТРУКЦИЙ / ОПРОСНИКА ----------
def _save_resource(self, source_path, default_name, title):
"""Копирует ресурс (docx/xlsx) в выбранное пользователем место."""
if not source_path or not os.path.exists(source_path):
messagebox.showerror("Справка", f"Файл не найден:\n{source_path}")
return
filename = filedialog.asksaveasfilename(
title=title,
defaultextension=os.path.splitext(default_name)[1],
initialfile=default_name,
filetypes=[("Документ", "*.docx"), ("Все файлы", "*.*")]
)
if not filename:
return
try:
import shutil
shutil.copy(source_path, filename)
self.log(f"💾 Сохранено: {filename}")
messagebox.showinfo("Справка", f"Файл сохранён:\n{filename}")
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось сохранить: {e}")
def download_user_manual(self):
from .help_docs import ensure_docs, user_manual_path
ensure_docs()
self._save_resource(user_manual_path(), "Инструкция_пользователя.docx", "Сохранить инструкцию пользователя")
def download_admin_manual(self):
from .help_docs import ensure_docs, admin_manual_path
ensure_docs()
self._save_resource(admin_manual_path(), "Инструкция_администратора.docx", "Сохранить инструкцию администратора")
def download_variables_doc(self):
from .help_docs import ensure_docs, variables_reference_path
ensure_docs()
self._save_resource(variables_reference_path(), "Переменные_шаблонов.docx", "Сохранить справочник переменных")
def download_survey(self):
from .help_docs import ensure_docs, survey_path
ensure_docs()
self._save_resource(survey_path(), "Опросный_лист_152_ФЗ.xlsx", "Сохранить пустой опросник")
def main():
"""Точка входа в приложение."""
root = tk.Tk()
app = DokoGenApp(root)
root.mainloop()