Files
LocoAgent 691b167725 Фикс UnboundLocalError в окне авторасчёта ПП-127
Причина: внутри _calc() в ветках стояли импорты
'from .variables import CRITERION_QUANT_THRESHOLDS...' — Python считает
эти имена локальными для всей функции, а условие
'if (num in CRITERION_QUANT_THRESHOLDS ...)' в начале _calc обращалось
к ним до присваивания → UnboundLocalError при любом расчёте.

Исправление: все импорты из .variables вынесены на уровень
_open_calc_dialog (начало функции); внутренние импорты из _calc()
и дубликаты в теле окна убраны.
2026-08-05 17:03:05 +04:00

5319 lines
286 KiB
Python
Raw Permalink 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 re
import sys
import threading
from datetime import datetime
from typing import List, Optional
from . import (
Company, CommissionMember, InformationSystem,
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, normalize_threats,
)
from .api_settings import load_api_settings, save_api_settings
# Бусредн — средний доход федерального бюджета за 3 года (данные Минфина).
# ВАЖНО: значение устаревает — обновлять ежегодно по данным Минфина или
# запрашивать у оператора в диалоге авторасчёта (поле редактируется).
DEFAULT_BUDGET_AVG = 21228053357700.0
class DokoGenApp:
"""Главное окно приложения."""
def __init__(self, root: tk.Tk):
self.root = root
root.title("ДоКоГеНеРаТоР — 187-ФЗ (КИИ)")
root.geometry("1200x800")
root.minsize(900, 600)
self._set_app_icon()
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_187.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 _set_app_icon(self):
"""Устанавливает иконку приложения на окно (заголовок, панель задач).
Работает и из исходников, и из exe (frozen):
- исходники: dokogen/icon.ico рядом с модулем
- exe: dokogen/icon.ico из _MEIPASS (ресурсы PyInstaller)
"""
try:
if getattr(sys, 'frozen', False):
base = getattr(sys, '_MEIPASS', os.path.dirname(sys.executable))
ico = os.path.join(base, 'dokogen', 'icon.ico')
if not os.path.exists(ico):
ico = os.path.join(os.path.dirname(sys.executable), 'icon.ico')
else:
ico = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'icon.ico')
if os.path.exists(ico):
self.root.iconbitmap(ico)
except Exception:
pass
# ==================== МЕНЮ ====================
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_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"),
("Доменное имя и внешний IP:", "domain_name"),
]
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)
# --- ОКВЭД ---
f_okved = ttk.LabelFrame(left, text="ОКВЭД", padding=10)
f_okved.pack(fill=tk.X, pady=5)
# Основной ОКВЭД — один, из словаря или вручную
ttk.Label(f_okved, text="Основной ОКВЭД:").grid(row=0, column=0, sticky=tk.W, pady=2)
okved_entry = ttk.Entry(f_okved, width=50)
okved_entry.grid(row=0, column=1, padx=10, sticky=tk.EW, pady=1)
self._company_entries['okved'] = okved_entry
ttk.Button(f_okved, text="Выбрать из словаря",
command=self._select_main_okved).grid(row=0, column=2, padx=5)
# Дополнительные ОКВЭД — сколько угодно
ttk.Label(f_okved, text="Дополнительные ОКВЭД:").grid(row=1, column=0, sticky=tk.NW, pady=2)
self.okved_extra_listbox = tk.Listbox(f_okved, height=4, width=50)
self.okved_extra_listbox.grid(row=1, column=1, padx=10, sticky=tk.EW, pady=1)
okved_btn = ttk.Frame(f_okved)
okved_btn.grid(row=1, column=2, padx=5, sticky=tk.N)
ttk.Button(okved_btn, text="Выбрать из словаря",
command=self._select_extra_okved).pack(fill=tk.X, pady=1)
ttk.Button(okved_btn, text="+ Добавить",
command=self._add_okved_manual).pack(fill=tk.X, pady=1)
ttk.Button(okved_btn, text="- Удалить",
command=self._delete_okved_extra).pack(fill=tk.X, pady=1)
f_okved.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)
# --- Финансовые показатели (опросник) ---
f_fin = ttk.LabelFrame(left, text="Финансовые показатели", padding=10)
f_fin.pack(fill=tk.X, pady=5)
fin_fields = [
("Сумма прибыли за предыдущий год (руб.):", "profit"),
("Сумма налога с прибыли (руб.):", "profit_tax"),
]
for i, (label, key) in enumerate(fin_fields):
ttk.Label(f_fin, text=label).grid(row=i, column=0, sticky=tk.W, pady=2)
entry = ttk.Entry(f_fin, width=50)
entry.grid(row=i, column=1, padx=10, sticky=tk.EW, pady=1)
self._company_entries[key] = entry
# Показатель 8 (ущерб субъекту КИИ): доход за 5 лет (суммарно) + суммарный ущерб
ttk.Button(f_fin, text="🌐 Загрузить доходы из DaData (по ИНН)",
command=self._load_incomes_from_dadata).grid(
row=len(fin_fields), column=2, padx=5, sticky=tk.W)
income_fields = [
("Доход за 5 лет (суммарно, руб.):", "income_5y"),
("Снижение уровня дохода от нарушения критических процессов (руб.):", "income_loss"),
]
for j, (label, key) in enumerate(income_fields):
ttk.Label(f_fin, text=label).grid(row=len(fin_fields) + 1 + j, column=0, sticky=tk.W, pady=2)
entry = ttk.Entry(f_fin, width=50)
entry.grid(row=len(fin_fields) + 1 + j, column=1, padx=10, sticky=tk.EW, pady=1)
self._company_entries[key] = entry
f_fin.columnconfigure(1, weight=1)
# --- Правая панель: проверка данных ---
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),
'okved': tk.BooleanVar(value=True),
'okved_extra': 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)
apply_row2 = ttk.Frame(right)
apply_row2.pack(fill=tk.X, pady=(2, 0))
ttk.Label(apply_row2, text="", font=('', 8), width=10).pack(side=tk.LEFT)
ttk.Checkbutton(apply_row2, text="ОКВЭД осн.", variable=self._apply_flags['okved']).pack(side=tk.LEFT)
ttk.Checkbutton(apply_row2, text="ОКВЭД доп.", variable=self._apply_flags['okved_extra']).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', 'Дата ОГРН'),
('okved', 'Основной ОКВЭД'),
('chief_position', 'Руководитель'), ('chief_fio', 'ФИО')]:
if d.get(key):
lines.append(f"{label}: {d[key]}")
if d.get('okved_extra'):
lines.append(f"Доп. ОКВЭД: {', '.join(d['okved_extra'])}")
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])
# ОКВЭД из DaData (с названием из словаря/интернета)
from .verify import resolve_okved
from .api_settings import load_api_settings as _las3
_tok3 = (_las3().get('dadata_token') or '')
if want('okved') and comp.get('okved') and 'okved' in self._company_entries:
self._company_entries['okved'].delete(0, tk.END)
self._company_entries['okved'].insert(0, resolve_okved(comp['okved'], _tok3))
if want('okved_extra') and comp.get('okved_extra') and hasattr(self, 'okved_extra_listbox'):
self.okved_extra_listbox.delete(0, tk.END)
from .verify import split_okved_extra
_applied = 0
for code in split_okved_extra(comp['okved_extra']):
if str(code).strip():
self.okved_extra_listbox.insert(tk.END, resolve_okved(str(code).strip(), _tok3))
_applied += 1
self.log(f"✅ Доп. ОКВЭД из DaData: применено {_applied} (всего в ответе: {len(comp['okved_extra'])})")
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=60)
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=60)
self.is_description_entry.grid(row=1, column=1, padx=10, sticky=tk.EW)
# Категория значимости объекта КИИ
self.is_category_var = tk.StringVar(value="без категории")
ttk.Label(fields_frame, text="Категория значимости:").grid(row=2, column=0, sticky=tk.W, pady=2)
cat_frame = ttk.Frame(fields_frame)
cat_frame.grid(row=2, column=1, sticky=tk.W, padx=10)
for val in ["1", "2", "3", "без категории"]:
ttk.Radiobutton(cat_frame, text=val, variable=self.is_category_var,
value=val).pack(side=tk.LEFT, padx=3)
ttk.Label(fields_frame, text="Балансовая стоимость (руб.):").grid(
row=3, column=0, sticky=tk.W, pady=2)
self.is_balance_entry = ttk.Entry(fields_frame, width=60)
self.is_balance_entry.grid(row=3, column=1, padx=10, sticky=tk.EW, pady=2)
fields_frame.columnconfigure(1, weight=1)
# ---- Вложенные вкладки ----
nb2 = ttk.Notebook(self._is_edit_panel)
nb2.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# ===== Вкладка «Основное» =====
main_tab = ttk.Frame(nb2)
nb2.add(main_tab, text="Основное")
main_frame = ttk.Frame(main_tab, padding=8)
main_frame.pack(fill=tk.BOTH, expand=True)
# Назначение объекта
ttk.Label(main_frame, text="Назначение объекта:").grid(row=0, column=0, sticky=tk.W, pady=2)
self.is_purpose_entry = ttk.Entry(main_frame, width=70)
self.is_purpose_entry.grid(row=0, column=1, padx=8, sticky=tk.EW, pady=1)
# Сфера деятельности — словарик + выпадающий список
ttk.Label(main_frame, text="Сфера деятельности:").grid(row=1, column=0, sticky=tk.W, pady=2)
self.is_sphere_combo = ttk.Combobox(main_frame, width=68, state='readonly')
self.is_sphere_combo.grid(row=1, column=1, padx=8, sticky=tk.EW, pady=1)
try:
import json as _json
_sp = os.path.join(os.path.dirname(__file__), 'dictionaries', 'spheres.json')
with open(_sp, encoding='utf-8') as _f:
self.is_sphere_combo['values'] = [''] + _json.load(_f)
except Exception:
pass
# Словарь типовых отраслевых объектов по сферам (Распоряжение ПРФ № 360-р)
try:
import json as _json
_tio = os.path.join(os.path.dirname(__file__), 'dictionaries', 'typical_industry_objects.json')
with open(_tio, encoding='utf-8') as _f:
self._typical_industry = _json.load(_f)
except Exception:
self._typical_industry = {}
self.is_sphere_combo.bind('<<ComboboxSelected>>', self._on_sphere_changed)
# Тип объекта — словарик + выпадающий список
ttk.Label(main_frame, text="Тип объекта:").grid(row=2, column=0, sticky=tk.W, pady=2)
self.is_object_type_combo = ttk.Combobox(main_frame, width=68, state='readonly')
self.is_object_type_combo.grid(row=2, column=1, padx=8, sticky=tk.EW, pady=1)
try:
_ot = os.path.join(os.path.dirname(__file__), 'dictionaries', 'object_types.json')
with open(_ot, encoding='utf-8') as _f:
self.is_object_type_combo['values'] = [''] + _json.load(_f)
except Exception:
pass
# Архитектура объекта — словарик + выпадающий список
ttk.Label(main_frame, text="Архитектура объекта:").grid(row=3, column=0, sticky=tk.W, pady=2)
self.is_architecture_combo = ttk.Combobox(main_frame, width=68, state='readonly')
self.is_architecture_combo.grid(row=3, column=1, padx=8, sticky=tk.EW, pady=1)
try:
_ar = os.path.join(os.path.dirname(__file__), 'dictionaries', 'architectures.json')
with open(_ar, encoding='utf-8') as _f:
self.is_architecture_combo['values'] = [''] + _json.load(_f)
except Exception:
pass
# Типовой отраслевой объект КИИ — словарик + выпадающий список
ttk.Label(main_frame, text="Типовой отраслевой объект КИИ:").grid(row=4, column=0, sticky=tk.W, pady=2)
self.is_typical_object_combo = ttk.Combobox(main_frame, width=68, state='readonly')
self.is_typical_object_combo.grid(row=4, column=1, padx=8, sticky=tk.EW, pady=1)
try:
_to = os.path.join(os.path.dirname(__file__), 'dictionaries', 'typical_objects.json')
with open(_to, encoding='utf-8') as _f:
self._typical_objects_all = [''] + _json.load(_f)
except Exception:
self._typical_objects_all = ['']
self.is_typical_object_combo['values'] = self._typical_objects_all
# Кнопки подбора характеристик: программа (типовой объект) и ИИ (всё)
suggest_row = ttk.Frame(main_frame)
suggest_row.grid(row=5, column=0, columnspan=2, sticky=tk.W, pady=(4, 0))
ttk.Button(suggest_row, text="⚙️ Подобрать типовой объект (программа)",
command=self._suggest_typical_program_click).pack(side=tk.LEFT, padx=2)
ttk.Button(suggest_row, text="🤖 Подобрать характеристики (ИИ)",
command=self._suggest_object_params_ai_click).pack(side=tk.LEFT, padx=2)
ttk.Label(suggest_row, text="Подбор по названию/назначению объекта — как рекомендация, оператор подтверждает.",
foreground='gray').pack(side=tk.LEFT, padx=8)
main_frame.columnconfigure(1, weight=1)
# ===== Вкладка «Субъект» =====
subj_tab = ttk.Frame(nb2)
nb2.add(subj_tab, text="Субъект")
subj_frame = ttk.Frame(subj_tab, padding=8)
subj_frame.pack(fill=tk.BOTH, expand=True)
subj_fields = [
("Наименование организации, эксплуатирующей объект:", "is_subject_name_entry"),
("Адрес местонахождения:", "is_subject_address_entry"),
("Элемент (компонент) объекта, который эксплуатируется лицом:", "is_subject_element_entry"),
("ИНН лица, эксплуатирующего объект:", "is_subject_inn_entry"),
("КПП его обособленных подразделений:", "is_subject_kpp_entry"),
]
for i, (label, attr) in enumerate(subj_fields):
ttk.Label(subj_frame, text=label).grid(row=i, column=0, sticky=tk.W, pady=2)
entry = ttk.Entry(subj_frame, width=70)
entry.grid(row=i, column=1, padx=8, sticky=tk.EW, pady=1)
setattr(self, attr, entry)
ttk.Button(subj_frame, text="📋 Заполнить из данных организации",
command=self._fill_subject_from_company).grid(
row=len(subj_fields), column=0, columnspan=2, sticky=tk.W, pady=(8, 0))
subj_frame.columnconfigure(1, weight=1)
# ===== Вкладка «Сеть» =====
net_tab = ttk.Frame(nb2)
nb2.add(net_tab, text="Сеть")
net_frame = ttk.Frame(net_tab, padding=8)
net_frame.pack(fill=tk.BOTH, expand=True)
# Категория сети — словарик
ttk.Label(net_frame, text="Категория сети электросвязи:").grid(row=1, column=0, sticky=tk.W, pady=2)
self.is_net_category_combo = ttk.Combobox(net_frame, width=68, state='readonly')
self.is_net_category_combo.grid(row=1, column=1, padx=8, sticky=tk.EW, pady=1)
try:
import json as _json
_nc = os.path.join(os.path.dirname(__file__), 'dictionaries', 'net_categories.json')
with open(_nc, encoding='utf-8') as _f:
self.is_net_category_combo['values'] = [''] + _json.load(_f)
except Exception:
pass
# Провайдер
ttk.Label(net_frame, text="Наименование провайдера:").grid(row=2, column=0, sticky=tk.W, pady=2)
self.is_net_operator_entry = ttk.Entry(net_frame, width=70)
self.is_net_operator_entry.grid(row=2, column=1, padx=8, sticky=tk.EW, pady=1)
# Цель взаимодействия — словарик
ttk.Label(net_frame, text="Цель взаимодействия с сетью электросвязи:").grid(row=3, column=0, sticky=tk.W, pady=2)
self.is_net_purpose_combo = ttk.Combobox(net_frame, width=68, state='readonly')
self.is_net_purpose_combo.grid(row=3, column=1, padx=8, sticky=tk.EW, pady=1)
try:
_np = os.path.join(os.path.dirname(__file__), 'dictionaries', 'net_purposes.json')
with open(_np, encoding='utf-8') as _f:
self.is_net_purpose_combo['values'] = [''] + _json.load(_f)
except Exception:
pass
# Способ взаимодействия — словарик
ttk.Label(net_frame, text="Способ взаимодействия (тип доступа):").grid(row=4, column=0, sticky=tk.W, pady=2)
self.is_net_method_combo = ttk.Combobox(net_frame, width=68, state='readonly')
self.is_net_method_combo.grid(row=4, column=1, padx=8, sticky=tk.EW, pady=1)
try:
_nm = os.path.join(os.path.dirname(__file__), 'dictionaries', 'net_methods.json')
with open(_nm, encoding='utf-8') as _f:
self.is_net_method_combo['values'] = [''] + _json.load(_f)
except Exception:
pass
net_frame.columnconfigure(1, weight=1)
# ===== Вкладка «ПО» (списки, как «Средства защиты») =====
sw_tab = ttk.Frame(nb2)
nb2.add(sw_tab, text="ПО")
sw_frame = ttk.Frame(sw_tab, padding=8)
sw_frame.pack(fill=tk.BOTH, expand=True)
sw_fields = [
("ПО и аппаратные средства (модели, производители, количество):", "is_hw_sw_listbox", "hw_sw.json"),
("Операционные системы:", "is_os_sw_listbox", "os_sw.json"),
("Прикладные программы (MS Office, OpenOffice и пр.):", "is_app_sw_listbox", "app_sw.json"),
]
for i, (label, attr, dict_name) in enumerate(sw_fields):
row = i * 2
ttk.Label(sw_frame, text=label).grid(row=row, column=0, sticky=tk.W, pady=(4, 0))
box = tk.Listbox(sw_frame, height=4, exportselection=False)
box.grid(row=row + 1, column=0, sticky=tk.EW, padx=(0, 4), pady=1)
sb = ttk.Scrollbar(sw_frame, orient=tk.VERTICAL, command=box.yview)
sb.grid(row=row + 1, column=1, sticky=tk.NS)
box.configure(yscrollcommand=sb.set)
btn = ttk.Frame(sw_frame)
btn.grid(row=row + 1, column=2, sticky=tk.N, padx=(4, 0))
ttk.Button(btn, text="+ Добавить",
command=lambda a=attr: self._add_sw_item(getattr(self, a))).pack(fill=tk.X, pady=1)
ttk.Button(btn, text="- Удалить",
command=lambda a=attr: self._del_sw_item(getattr(self, a))).pack(fill=tk.X, pady=1)
ttk.Button(btn, text="✏️ Редактировать",
command=lambda a=attr: self._edit_sw_item(getattr(self, a))).pack(fill=tk.X, pady=1)
ttk.Button(btn, text="📚 Из словаря",
command=lambda a=attr, d=dict_name: self._select_sw_from_dict(getattr(self, a), d, allow_refine=True)).pack(fill=tk.X, pady=1)
setattr(self, attr, box)
sw_frame.columnconfigure(0, weight=1)
# ===== Вкладка «Безопасность» =====
# ===== Вкладка «Безопасность» — с вложенными подвкладками =====
sec_tab = ttk.Frame(nb2)
nb2.add(sec_tab, text="Безопасность")
sec_frame = ttk.Frame(sec_tab, padding=6)
sec_frame.pack(fill=tk.BOTH, expand=True)
nb_sec = ttk.Notebook(sec_frame)
nb_sec.pack(fill=tk.BOTH, expand=True)
# --- Подвкладка 1: Основные данные (нарушитель + автозаполнение) ---
sec_main = ttk.Frame(nb_sec, padding=8)
nb_sec.add(sec_main, text="Основные данные")
ttk.Label(sec_main, text="Модель нарушителя (Методика ФСТЭК от 05.02.2021):",
font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=(0, 4))
row1 = ttk.Frame(sec_main)
row1.pack(fill=tk.X, pady=3)
ttk.Label(row1, text="Категория нарушителя:", width=26).pack(side=tk.LEFT)
self.is_attacker_category_combo = ttk.Combobox(row1, state='readonly', width=50)
self.is_attacker_category_combo.pack(side=tk.LEFT, fill=tk.X, expand=True)
try:
import json as _json
_ac = os.path.join(os.path.dirname(__file__), 'dictionaries', 'attacker_categories.json')
with open(_ac, encoding='utf-8') as _f:
_cats = _json.load(_f)
self.is_attacker_category_combo['values'] = [''] + [c['name'] for c in _cats]
except Exception:
pass
row2 = ttk.Frame(sec_main)
row2.pack(fill=tk.X, pady=3)
ttk.Label(row2, text="Уровень возможностей нарушителя:", width=26).pack(side=tk.LEFT)
self.is_attacker_level_combo = ttk.Combobox(row2, state='readonly', width=50)
self.is_attacker_level_combo.pack(side=tk.LEFT, fill=tk.X, expand=True)
try:
import json as _json
_al = os.path.join(os.path.dirname(__file__), 'dictionaries', 'attacker_levels.json')
with open(_al, encoding='utf-8') as _f:
_levels = _json.load(_f)
self.is_attacker_level_combo['values'] = [''] + [
f"{l['level']}{l['name']}" for l in _levels]
except Exception:
pass
ttk.Label(sec_main, text="Виды нарушителей:").pack(anchor=tk.W, pady=(6, 2))
sec_at_row = ttk.Frame(sec_main)
sec_at_row.pack(fill=tk.BOTH, expand=True)
self.is_attacker_types_listbox = tk.Listbox(sec_at_row, height=6, exportselection=False)
self.is_attacker_types_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
sec_at_btn = ttk.Frame(sec_at_row)
sec_at_btn.pack(side=tk.RIGHT, padx=(4, 0))
ttk.Button(sec_at_btn, text="-", width=2,
command=lambda: self._del_sw_item(self.is_attacker_types_listbox)).pack(pady=1)
ttk.Button(sec_at_btn, text="📚", width=3,
command=lambda: self._select_sw_from_dict(self.is_attacker_types_listbox, 'attacker_types.json')).pack(pady=1)
sec_main_btns = ttk.Frame(sec_main)
sec_main_btns.pack(fill=tk.X, pady=(8, 0))
ttk.Button(sec_main_btns, text="⚡ Автозаполнить (нарушитель, угрозы, меры)",
command=self._auto_fill_security_click).pack(side=tk.LEFT, padx=2)
ttk.Button(sec_main_btns, text="🤖 Проверить безопасность через ИИ",
command=self._check_security_ai).pack(side=tk.LEFT, padx=2)
ttk.Button(sec_main_btns, text="🧩 Проверить объект по частям (ИИ)",
command=self._check_object_partial_click).pack(side=tk.LEFT, padx=2)
ttk.Label(sec_main_btns,
text="Подбор по данным объекта — как рекомендация, оператор подтверждает.",
foreground='gray').pack(side=tk.LEFT, padx=8)
# --- Подвкладка 2: Угрозы ---
sec_threats = ttk.Frame(nb_sec, padding=8)
nb_sec.add(sec_threats, text="Угрозы")
thr_actions = ttk.Frame(sec_threats)
thr_actions.pack(fill=tk.X, pady=(0, 6))
ttk.Button(thr_actions, text="⚡ Подобрать угрозы автоматически",
command=self._suggest_threats_program_click).pack(side=tk.LEFT, padx=2)
ttk.Button(thr_actions, text="🤖 Подобрать через ИИ",
command=self._suggest_threats_ai_click).pack(side=tk.LEFT, padx=2)
ttk.Button(thr_actions, text="🤖 Проверить через ИИ",
command=self._check_threats_ai_click).pack(side=tk.LEFT, padx=2)
ttk.Button(thr_actions, text="🎯 Подобрать угрозы (программа+ИИ)",
command=self._open_threats_picker).pack(side=tk.LEFT, padx=2)
ttk.Button(thr_actions, text="⬇️ Загрузить угрозы ФСТЭК",
command=self._download_fstec_threats).pack(side=tk.LEFT, padx=2)
self._threats_date_label = ttk.Label(thr_actions, text="", foreground='gray')
self._threats_date_label.pack(side=tk.LEFT, padx=8)
thr_row = ttk.Frame(sec_threats)
thr_row.pack(fill=tk.BOTH, expand=True)
self.is_threats_listbox = tk.Listbox(thr_row, height=10, exportselection=False)
self.is_threats_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
thr_btn = ttk.Frame(thr_row)
thr_btn.pack(side=tk.RIGHT, padx=(4, 0))
ttk.Button(thr_btn, text="- Удалить",
command=lambda: self._del_sw_item(self.is_threats_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(thr_btn, text="📚 Из словаря",
command=lambda: self._select_sw_from_dict(self.is_threats_listbox, 'threats_fstec.json')).pack(fill=tk.X, pady=1)
# --- Подвкладка 3: Инциденты и критерии ---
sec_inc = ttk.Frame(nb_sec, padding=8)
nb_sec.add(sec_inc, text="Инциденты и критерии")
def _sec_row(parent, label, attr, dict_name=None, height=2, allow_add=False):
"""Ряд: label + listbox + кнопки (+/-/✏️/📚)."""
ttk.Label(parent, text=label).pack(anchor=tk.W)
row = ttk.Frame(parent)
row.pack(fill=tk.X, pady=(2, 4))
lb = tk.Listbox(row, height=height, exportselection=False)
lb.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
setattr(self, attr, lb)
btns = ttk.Frame(row)
btns.pack(side=tk.RIGHT, padx=(4, 0))
if allow_add or not dict_name:
ttk.Button(btns, text="+", width=2,
command=lambda l=lb: self._add_sw_item(l)).pack(pady=1)
ttk.Button(btns, text="-", width=2,
command=lambda l=lb: self._del_sw_item(l)).pack(pady=1)
ttk.Button(btns, text="✏️", width=3,
command=lambda l=lb: self._edit_sw_item(l)).pack(pady=1)
if dict_name:
ttk.Button(btns, text="📚", width=3,
command=lambda l=lb, d=dict_name: self._select_sw_from_dict(l, d)).pack(pady=1)
_sec_row(sec_inc, "Типы компьютерных инцидентов:",
'is_incidents_listbox', 'incidents.json', height=3)
_sec_row(sec_inc, "Показатели критериев значимости:",
'is_criteria_values_listbox', 'criteria_indicators.json', height=3)
btns_inc = ttk.Frame(sec_inc)
btns_inc.pack(anchor=tk.W, pady=(0, 4))
ttk.Button(btns_inc, text="📋 Заполнить по таблице ПП № 127",
command=self._open_criteria_127_dialog).pack(side=tk.LEFT, padx=2)
ttk.Button(btns_inc, text="🤖 Проверить категории через ИИ",
command=self._check_criteria_ai).pack(side=tk.LEFT, padx=2)
_sec_row(sec_inc, "Обоснование значений показателей:",
'is_criteria_justification_listbox', None, height=3)
# --- Подвкладка 4: Организационные и технические меры ---
sec_meas = ttk.Frame(nb_sec, padding=8)
nb_sec.add(sec_meas, text="Орг. и тех. меры")
_sec_row(sec_meas, "Организационные меры:",
'is_org_measures_listbox', 'org_measures.json', height=5, allow_add=True)
_sec_row(sec_meas, "Технические меры:",
'is_tech_measures_listbox', 'tech_measures.json', height=5, allow_add=True)
# --- Подвкладка 5: Средства защиты (СЗИ) — внутри «Безопасности» ---
sec_def = ttk.Frame(nb_sec, padding=8)
nb_sec.add(sec_def, text="Средства защиты (СЗИ)")
def_btn_frame = ttk.Frame(sec_def)
def_btn_frame.pack(fill=tk.X, pady=(0, 4))
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)
ttk.Button(def_btn_frame, text="🤖 Рекомендации ИИ по СЗИ",
command=self._suggest_defense_ai).pack(side=tk.LEFT, padx=2)
self.defense_tools_listbox = tk.Listbox(sec_def, 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)
# Дата актуализации списка угроз ФСТЭК (если уже скачивался)
self._update_threats_date_label()
# ---------- ВКЛАДКА "КОМИССИЯ" ----------
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_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()
if hasattr(self, 'okved_extra_listbox'):
c.okved_extra = list(self.okved_extra_listbox.get(0, tk.END))
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_software_entry'):
isys.software = self.is_software_entry.get().strip()
isys.category = self.is_category_var.get()
if hasattr(self, 'is_balance_entry'):
isys.balance_value = self.is_balance_entry.get().strip()
# Поля 236 приказа
for attr, model_field in [
('is_purpose_entry', 'purpose'),
('is_sphere_combo', 'sphere'),
('is_object_type_combo', 'object_type'),
('is_architecture_combo', 'architecture'),
('is_typical_object_combo', 'typical_object'),
('is_subject_name_entry', 'subject_name'),
('is_subject_address_entry', 'subject_address'),
('is_subject_element_entry', 'subject_element'),
('is_subject_inn_entry', 'subject_inn'),
('is_subject_kpp_entry', 'subject_kpp'),
('is_net_category_combo', 'net_category'),
('is_net_operator_entry', 'net_operator'),
('is_net_purpose_combo', 'net_purpose'),
('is_net_method_combo', 'net_method'),
('is_hw_sw_listbox', 'hw_sw'),
('is_os_sw_listbox', 'os_sw'),
('is_app_sw_listbox', 'app_sw'),
('is_attacker_category_combo', 'attacker_category'),
('is_attacker_level_combo', 'attacker_level'),
('is_attacker_types_listbox', 'attacker_types'),
('is_threats_listbox', 'threats'),
('is_incidents_listbox', 'incidents'),
('is_criteria_values_listbox', 'criteria_values'),
('is_criteria_justification_listbox', 'criteria_justification'),
('is_org_measures_listbox', 'org_measures'),
('is_tech_measures_listbox', 'tech_measures'),
]:
w = getattr(self, attr, None)
if w is not None:
if isinstance(w, tk.Listbox):
setattr(isys, model_field, '\n'.join(w.get(0, tk.END)))
elif isinstance(w, tk.Text):
setattr(isys, model_field, w.get('1.0', 'end-1c').strip())
else:
setattr(isys, model_field, w.get().strip())
# Списки
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 "")
# Дополнительные ОКВЭД — нарезаем по кодам и нормализуем в «код название»
if hasattr(self, 'okved_extra_listbox'):
self.okved_extra_listbox.delete(0, tk.END)
try:
from .verify import split_okved_extra, resolve_okved
from .api_settings import load_api_settings as _las_g
_tok_g = (_las_g().get('dadata_token') or '')
for item in split_okved_extra(c.okved_extra or []):
self.okved_extra_listbox.insert(tk.END, resolve_okved(item, _tok_g))
except Exception:
for item in (c.okved_extra or []):
self.okved_extra_listbox.insert(tk.END, item)
# Склонения
idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}
for code, entry in self.decl_entries.items():
entry.delete(0, tk.END)
entry.insert(0, getattr(c, f"company_name_{idx_map[code]}", "") or "")
for code, entry in self.short_decl_entries.items():
entry.delete(0, tk.END)
entry.insert(0, getattr(c, f"short_name_{idx_map[code]}", "") or "")
# Комиссия
for frame in list(self._comm_members_frame.winfo_children()):
frame.destroy()
self.commission_entries.clear()
for member in c.commission:
self._add_commission_row(member.role, member.position, member.fio)
if not self.commission_entries:
for _ in range(3):
self._add_commission_row()
# ИС
self._refresh_is_listbox()
if self.model.information_systems:
self.is_listbox.selection_set(0)
self._on_is_select()
# Пользователи
if hasattr(self, '_employee_entries'):
for row_frame, _, _ in self._employee_entries:
row_frame.destroy()
self._employee_entries.clear()
for emp in c.employees_access:
self._add_employee_row(
emp.get('position', ''),
emp.get('fio', ''),
)
self._update_emp_count()
# Шаблоны
self._on_templates_changed()
# Автоопределение города из адреса после загрузки
self._on_address_changed()
# ==================== ГОРОД ИЗ АДРЕСА ====================
def _extract_city_from_address(self, address: str) -> str:
"""Извлекает название города из адреса (простая эвристика)."""
if not address:
return ''
# Пробуем найти "г. Город" или "Г Город" (без точки, заглавная)
import re
m = re.search(r'\b[гГ]\.?\s*([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address)
if m:
return m.group(1).strip()
# Пробуем найти "город Город"
m = re.search(r'\bгород\s+([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address, re.IGNORECASE)
if m:
return m.group(1).strip()
# Пробуем первую часть до запятой, если там известный город
parts = [p.strip() for p in address.split(',')]
if parts:
first = parts[0]
# Если первая часть — короткая и не похожа на улицу/дом
if len(first) <= 25 and first and not first.startswith(('ул', 'пр', 'пер', 'шоссе')):
# Проверяем, что это похоже на город
city_keywords = ['ск', 'цк', 'бург', 'град', 'полис', 'поль', 'стан', 'горск', 'чинск', 'янск', 'овск']
if any(first.lower().endswith(kw) for kw in city_keywords):
return first
return ''
def _on_address_changed(self, event=None):
"""Обработчик изменения адреса — автозаполняет город."""
addr_entry = self._company_entries.get('address')
city_entry = self._company_entries.get('city_name')
if not addr_entry or not city_entry:
return
address = addr_entry.get().strip()
if not address:
return
current_city = city_entry.get().strip()
if current_city:
return # Не перезаписываем, если уже заполнен
city = self._extract_city_from_address(address)
if city:
city_entry.delete(0, tk.END)
city_entry.insert(0, city)
self.log(f"🏙 Город определён: {city}")
# ==================== АВТОРАСЧЁТ УРОВНЯ ЗАЩИЩЁННОСТИ ====================
# ==================== КОМИССИЯ ====================
def _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'
def _suggest_typical_program_click(self):
"""⚙️ Подобрать типовой отраслевой объект программой (по названию/назначению объекта)."""
if self._current_is_idx is None:
messagebox.showinfo("Подбор", "Сначала выберите объект КИИ в списке слева")
return
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
try:
from .threat_rules import suggest_typical_object_program
sphere = self.is_sphere_combo.get() or None
results = suggest_typical_object_program(isys, limit=12, sphere=sphere)
except Exception as e:
messagebox.showerror("Ошибка", str(e))
return
if not results:
messagebox.showinfo("Подбор",
"Не удалось подобрать типовой объект по названию/назначению.\n"
"Попробуйте кнопку «🤖 Подобрать характеристики (ИИ)».")
return
dlg = tk.Toplevel(self.root)
dlg.title("⚙️ Подбор типового отраслевого объекта")
dlg.geometry("860x520")
dlg.transient(self.root)
frm = ttk.Frame(dlg, padding=8)
frm.pack(fill=tk.BOTH, expand=True)
ttk.Label(frm, text=f"Объект: {isys.name}",
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
ttk.Label(frm, text="Наиболее подходящие типовые отраслевые объекты (по совпадению слов "
"с названием/назначением). Выберите и нажмите «Применить».",
foreground='gray', wraplength=820, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
lb = tk.Listbox(frm, height=16, exportselection=False)
lb.pack(fill=tk.BOTH, expand=True)
for r in results:
lb.insert(tk.END, f"[{r['score']}] ({r['sphere']}) {r['name']}")
btn_row = ttk.Frame(frm)
btn_row.pack(fill=tk.X, pady=(8, 0))
ttk.Button(btn_row, text="✔️ Применить выбранное",
command=lambda: self._apply_typical_selection(lb, results, dlg)).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="Отмена", command=dlg.destroy).pack(side=tk.LEFT, padx=2)
def _apply_typical_selection(self, lb, results, dlg):
sel = lb.curselection()
if not sel:
messagebox.showinfo("Подбор", "Выберите типовой объект из списка")
return
r = results[sel[0]]
# Устанавливаем сферу и типовой объект
if hasattr(self, 'is_sphere_combo'):
self.is_sphere_combo.set(r['sphere'])
self._update_typical_objects_by_sphere()
if hasattr(self, 'is_typical_object_combo'):
self.is_typical_object_combo.set(r['name'])
self._collect_is()
self.log(f"⚙️ Типовой отраслевой объект: {r['name'][:70]}")
dlg.destroy()
def _suggest_object_params_ai_click(self):
"""🤖 Подобрать характеристики объекта через ИИ (назначение, сфера, тип, архитектура, типовой объект)."""
if self._current_is_idx is None:
messagebox.showinfo("Подбор", "Сначала выберите объект КИИ в списке слева")
return
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
# Перечень типовых объектов для выбора ИИ (всех сфер)
typical_list = []
if hasattr(self, '_typical_industry'):
for sph, items in self._typical_industry.items():
if sph == 'Иная сфера деятельности':
continue
for it in items:
typical_list.append(it.get('name', ''))
data = {
'name': isys.name or '',
'description': isys.description or '',
'purpose': isys.purpose or '',
'sphere': isys.sphere or '',
'object_type': isys.object_type or '',
}
# Полные списки допустимых значений — ИИ выбирает ТОЛЬКО из них
spheres = list(self.is_sphere_combo['values']) if hasattr(self, 'is_sphere_combo') else []
object_types = list(self.is_object_type_combo['values']) if hasattr(self, 'is_object_type_combo') else []
architectures = list(self.is_architecture_combo['values']) if hasattr(self, 'is_architecture_combo') else []
self.log(f"🤖 Запрос к ИИ: подбор характеристик для «{isys.name}»")
self._verify_log("Подбор характеристик через ИИ…")
def _run():
try:
from .verify import suggest_object_params_ai
res, err = suggest_object_params_ai(
data, settings['ai_api_key'], settings.get('ai_model'),
settings.get('ai_base_url'), typical_objects=typical_list,
spheres=spheres, object_types=object_types, architectures=architectures)
self.root.after(0, lambda: self._suggest_object_params_ai_done(res, err, isys.name))
except Exception as e:
self.root.after(0, lambda: self._suggest_object_params_ai_done(None, str(e), isys.name))
threading.Thread(target=_run, daemon=True).start()
def _suggest_object_params_ai_done(self, res, err, obj_name):
if err:
self._verify_log(f"❌ {err}")
self.log(f"❌ Подбор характеристик ИИ: {err}")
return
if not res:
self._verify_log("❌ ИИ не вернул характеристики")
return
dlg = tk.Toplevel(self.root)
dlg.title("🤖 Рекомендации ИИ по характеристикам объекта")
dlg.geometry("880x620")
dlg.transient(self.root)
frm = ttk.Frame(dlg, padding=8)
frm.pack(fill=tk.BOTH, expand=True)
ttk.Label(frm, text=f"Объект: {obj_name} — рекомендации ИИ. Отметьте галочками, "
f"какие поля применить (ИИ может ошибаться — проверяйте).",
font=('Arial', 10, 'bold'), wraplength=840, justify=tk.LEFT).pack(anchor=tk.W)
rows = [
('назначение', 'Назначение объекта (форма: «Объект предназначен для … с целью …»)'),
('сфера', 'Сфера деятельности'),
('тип', 'Тип объекта'),
('архитектура', 'Архитектура объекта'),
('типовой_объект', 'Типовой отраслевой объект КИИ'),
]
entries = {}
checks = {}
for key, label in rows:
row = ttk.Frame(frm)
row.pack(fill=tk.X, pady=(6, 0))
var = tk.BooleanVar(value=True)
cb = ttk.Checkbutton(row, text=label, variable=var)
cb.pack(anchor=tk.W)
e = tk.Text(frm, height=2 if key in ('типовой_объект', 'назначение') else 1, wrap=tk.WORD)
e.pack(fill=tk.X)
e.insert('1.0', res.get(key, '') or '')
entries[key] = e
checks[key] = var
ttk.Label(frm, text="Обоснование ИИ:").pack(anchor=tk.W, pady=(6, 0))
just = tk.Text(frm, height=2, wrap=tk.WORD)
just.pack(fill=tk.X)
just.insert('1.0', res.get('обоснование', '') or '')
btn_row = ttk.Frame(frm)
btn_row.pack(fill=tk.X, pady=(8, 0))
ttk.Button(btn_row, text="✔️ Применить отмеченное",
command=lambda: self._apply_ai_params(entries, dlg, checks)).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="Отмена", command=dlg.destroy).pack(side=tk.LEFT, padx=2)
def _apply_ai_params(self, entries, dlg, checks=None):
"""Применить рекомендации ИИ к полям объекта (только отмеченные галочками)."""
def _get(e):
return e.get('1.0', 'end-1c').strip()
def _wanted(key):
return checks is None or checks.get(key, tk.BooleanVar(value=True)).get()
purpose = _get(entries['назначение']) if _wanted('назначение') else ''
sphere = _get(entries['сфера']) if _wanted('сфера') else ''
otype = _get(entries['тип']) if _wanted('тип') else ''
arch = _get(entries['архитектура']) if _wanted('архитектура') else ''
typical = _get(entries['типовой_объект']) if _wanted('типовой_объект') else ''
if purpose and hasattr(self, 'is_purpose_entry'):
self.is_purpose_entry.delete(0, tk.END)
self.is_purpose_entry.insert(0, purpose)
# Сфера — проверим, есть ли в списке
if sphere and hasattr(self, 'is_sphere_combo'):
values = list(self.is_sphere_combo['values'])
exact = next((v for v in values if v.lower() == sphere.lower()), None)
if exact:
self.is_sphere_combo.set(exact)
self._update_typical_objects_by_sphere()
else:
self.is_sphere_combo.set(sphere)
if otype and hasattr(self, 'is_object_type_combo'):
values = list(self.is_object_type_combo['values'])
exact = next((v for v in values if v.lower() == otype.lower()), None)
self.is_object_type_combo.set(exact or otype)
if arch and hasattr(self, 'is_architecture_combo'):
values = list(self.is_architecture_combo['values'])
exact = next((v for v in values if v.lower() == arch.lower()), None)
self.is_architecture_combo.set(exact or arch)
if typical and hasattr(self, 'is_typical_object_combo'):
values = list(self.is_typical_object_combo['values'])
exact = next((v for v in values if v.lower() == typical.lower()), None)
self.is_typical_object_combo.set(exact or typical)
self._collect_is()
self.log("🤖 Рекомендации ИИ применены к характеристикам объекта")
dlg.destroy()
def _on_sphere_changed(self, event=None):
"""При смене сферы объекта — подставляем типовые отраслевые объекты этой сферы (Распоряжение ПРФ № 360-р)."""
self._update_typical_objects_by_sphere()
def _update_typical_objects_by_sphere(self):
"""Обновляет список типовых отраслевых объектов по выбранной сфере объекта КИИ."""
try:
sphere = self.is_sphere_combo.get()
if sphere and hasattr(self, '_typical_industry') and sphere in self._typical_industry:
items = self._typical_industry[sphere]
if items:
values = [''] + [o['name'] for o in items]
self.is_typical_object_combo['values'] = values
return
# Сферы нет / словарь пуст — общий словарь типовых объектов
if hasattr(self, '_typical_objects_all'):
self.is_typical_object_combo['values'] = self._typical_objects_all
except Exception:
pass
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_software_entry'):
self.is_software_entry.delete(0, tk.END)
self.is_software_entry.insert(0, isys.software or "")
self.is_category_var.set(isys.category or "без категории")
if hasattr(self, 'is_balance_entry'):
self.is_balance_entry.delete(0, tk.END)
self.is_balance_entry.insert(0, isys.balance_value or "")
# Поля 236 приказа
for attr, model_field in [
('is_purpose_entry', 'purpose'),
('is_sphere_combo', 'sphere'),
('is_object_type_combo', 'object_type'),
('is_architecture_combo', 'architecture'),
('is_typical_object_combo', 'typical_object'),
('is_subject_name_entry', 'subject_name'),
('is_subject_address_entry', 'subject_address'),
('is_subject_element_entry', 'subject_element'),
('is_subject_inn_entry', 'subject_inn'),
('is_subject_kpp_entry', 'subject_kpp'),
('is_net_category_combo', 'net_category'),
('is_net_operator_entry', 'net_operator'),
('is_net_purpose_combo', 'net_purpose'),
('is_net_method_combo', 'net_method'),
('is_hw_sw_listbox', 'hw_sw'),
('is_os_sw_listbox', 'os_sw'),
('is_app_sw_listbox', 'app_sw'),
('is_attacker_category_combo', 'attacker_category'),
('is_threats_listbox', 'threats'),
('is_incidents_listbox', 'incidents'),
('is_criteria_values_listbox', 'criteria_values'),
('is_criteria_justification_listbox', 'criteria_justification'),
('is_org_measures_listbox', 'org_measures'),
('is_tech_measures_listbox', 'tech_measures'),
]:
w = getattr(self, attr, None)
if w is not None:
val = getattr(isys, model_field, '') or ''
if isinstance(w, ttk.Combobox):
w.set(val)
elif isinstance(w, tk.Listbox):
w.delete(0, tk.END)
val = str(val)
if model_field in ('threats', 'incidents'):
val = normalize_threats(val)
lines = [ln.strip() for ln in val.split('\n') if ln.strip()]
if model_field == 'threats':
from .threat_rules import sort_threats_by_ubi
lines = sort_threats_by_ubi(lines)
for line in lines:
w.insert(tk.END, line)
elif isinstance(w, tk.Text):
w.delete('1.0', tk.END)
w.insert('1.0', val)
else:
w.delete(0, tk.END)
w.insert(0, val)
# Типовые отраслевые объекты — по сфере объекта (Распоряжение ПРФ № 360-р)
self._update_typical_objects_by_sphere()
# Списки
for lb, items in [
(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,
category=src.category,
defense_tools_list=list(src.defense_tools_list),
users_list=list(src.users_list),
purpose=src.purpose,
sphere=src.sphere,
object_type=src.object_type,
architecture=src.architecture,
typical_object=src.typical_object,
subject_name=src.subject_name,
subject_address=src.subject_address,
subject_element=src.subject_element,
subject_inn=src.subject_inn,
subject_kpp=src.subject_kpp,
net_category=src.net_category,
net_operator=src.net_operator,
net_purpose=src.net_purpose,
net_method=src.net_method,
hw_sw=src.hw_sw,
os_sw=src.os_sw,
app_sw=src.app_sw,
balance_value=src.balance_value,
attacker_category=src.attacker_category,
attacker_level=src.attacker_level,
attacker_types=src.attacker_types,
threats=src.threats,
incidents=src.incidents,
criteria_values=src.criteria_values,
criteria_justification=src.criteria_justification,
org_measures=src.org_measures,
tech_measures=src.tech_measures,
)
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_defense_item(self):
item = simpledialog.askstring("Добавить СЗИ", "Введите средство защиты:")
if item:
self.defense_tools_listbox.insert(tk.END, item)
def _select_sw_from_dict(self, listbox, dict_name, allow_refine=False):
"""Выбор позиций из словаря (JSON) — можно несколько.
allow_refine=True — показывать кнопку «✏️ Уточнить модель/кол-во»
(только для ПО/оборудования, где это имеет смысл).
"""
import json
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', dict_name)
try:
with open(dict_path, 'r', encoding='utf-8') as f:
items = json.load(f)
title = "Выбор из словаря"
self._select_from_dialog(title, items, listbox, allow_refine=allow_refine)
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
def _sw_dialog(self, title, initial=""):
"""Большое окно для ввода/редактирования позиции ПО."""
dialog = tk.Toplevel(self.root)
dialog.title(title)
dialog.geometry("560x220")
dialog.transient(self.root)
dialog.grab_set()
dialog.resizable(True, True)
frame = ttk.Frame(dialog, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text="Текст позиции:").pack(anchor=tk.W)
txt = tk.Text(frame, width=70, height=6, wrap=tk.WORD)
txt.pack(fill=tk.BOTH, expand=True, pady=5)
txt.insert('1.0', initial)
txt.focus_set()
result = {}
def _ok():
result['val'] = txt.get('1.0', 'end-1c').strip()
dialog.destroy()
def _cancel():
result['val'] = None
dialog.destroy()
btn_frame = ttk.Frame(frame)
btn_frame.pack(fill=tk.X)
ttk.Button(btn_frame, text="✅ ОК", command=_ok).pack(side=tk.RIGHT, padx=2)
ttk.Button(btn_frame, text="Отмена", command=_cancel).pack(side=tk.RIGHT, padx=2)
dialog.bind('<Return>', lambda e: _ok())
dialog.bind('<Escape>', lambda e: _cancel())
dialog.wait_window()
return result.get('val')
def _add_sw_item(self, listbox):
"""Добавить строку в список ПО (большое окно)."""
item = self._sw_dialog("Добавить позицию")
if item:
listbox.insert(tk.END, item)
def _del_sw_item(self, listbox):
"""Удалить выбранную строку из списка ПО."""
sel = listbox.curselection()
if sel:
listbox.delete(sel[0])
def _edit_sw_item(self, listbox):
"""Редактировать выбранную строку списка ПО (большое окно)."""
sel = listbox.curselection()
if not sel:
messagebox.showinfo("Редактировать", "Сначала выберите позицию в списке")
return
current = listbox.get(sel[0])
item = self._sw_dialog("Редактировать позицию", initial=current)
if item is not None:
listbox.delete(sel[0])
listbox.insert(sel[0], item)
def _add_user_to_is(self):
user = simpledialog.askstring("Добавить пользователя", "Введите ФИО или должность:")
if user:
self.users_listbox.insert(tk.END, user)
def _open_criteria_127_dialog(self):
"""Окно заполнения показателей критериев значимости по ПП РФ № 127.
Таблица перенесена дословно из Постановления Правительства РФ от 08.02.2018 № 127:
5 критериев (социальная, политическая, экономическая, экологическая,
значимость для обороны страны), показатели 1-14 с подпунктами,
значения сразу привязаны к категориям III / II / I.
Категория значимости объекта определяется по наибольшей категории,
которой соответствует хотя бы один выбранный показатель:
I → 1-я категория, II → 2-я, III → 3-я, иначе «без категории».
"""
import json as _json
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'criteria_127.json')
try:
with open(dict_path, encoding='utf-8') as f:
criteria = _json.load(f)
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось загрузить таблицу ПП 127: {e}")
return
# Текущий объект КИИ (для авторасчёта п. 8/9: доходы, ущерб, балансовая стоимость)
isys = None
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]
# Применимость показателей по сфере объекта (Распоряжение ПРФ № 360-р)
applicable = None # None = все показатели применимы
try:
_app_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'criteria_applicability.json')
with open(_app_path, encoding='utf-8') as _f:
_app = _json.load(_f)
_sphere = self.is_sphere_combo.get() if hasattr(self, 'is_sphere_combo') else ''
if _sphere and _sphere in _app:
applicable = set(_app[_sphere])
except Exception:
applicable = None
# Словарь обоснований неприменимости показателей по сферам
# (примеры формулировок; для сфер без записей — пусто)
justifications = {}
try:
_j_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'criteria_justifications.json')
with open(_j_path, encoding='utf-8') as _f:
justifications = _json.load(_f)
except Exception:
justifications = {}
_sphere_just = self.is_sphere_combo.get() if hasattr(self, 'is_sphere_combo') else ''
_just = justifications.get(_sphere_just, {}) if _sphere_just else {}
def _just_text(num):
"""Обоснование для показателя: точный ключ, иначе группа по первым цифрам
(2а→2, 3б→3, 10.6→10, 13а→13)."""
if num in _just:
return _just[num]
m = _re.match(r'(\d+)', num)
if m and m.group(1) in _just:
return _just[m.group(1)]
return None
def _unlock(combo, var, lbl_num, lbl_name, name, btn=None, full_options=None):
"""Разблокировать конкретный показатель: вернуть категории и «—» (прочерк)."""
try:
combo.config(state='readonly')
combo.config(values=full_options if full_options else ['', '—'])
var.set('')
lbl_num.config(foreground='black')
lbl_name.config(foreground='black', text=name)
if btn is not None:
btn.destroy()
except Exception:
pass
dialog = tk.Toplevel(self.root)
dialog.title("Показатели критериев значимости (ПП РФ № 127)")
dialog.geometry("980x700")
dialog.transient(self.root)
dialog.grab_set()
outer = ttk.Frame(dialog, padding=8)
outer.pack(fill=tk.BOTH, expand=True)
canvas = tk.Canvas(outer, highlightthickness=0)
scrollbar = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
body = ttk.Frame(canvas)
body.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=body, 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)
ttk.Label(body, text="Показатели критериев значимости объекта КИИ",
font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=(0, 2))
ttk.Label(body, text="Постановление Правительства РФ от 08.02.2018 № 127 «Об утверждении Правил категорирования "
"объектов критической информационной инфраструктуры РФ» (приложение). "
"По каждому показателю выберите значение, которому соответствует объект. "
"Категория определяется по наивысшей из выбранных.",
foreground='gray', wraplength=920, justify=tk.LEFT).pack(anchor=tk.W, pady=(0, 4))
if applicable is not None:
_sphere_txt = self.is_sphere_combo.get() if hasattr(self, 'is_sphere_combo') else ''
ttk.Label(body, text=f"Сфера объекта: «{_sphere_txt}». Показатели, не применимые для этой сферы "
f"(по Распоряжению ПРФ № 360-р), автоматически помечены «не применим» и "
f"заблокированы. Кнопка «🔓» разблокирует конкретный показатель, если оператор "
f"считает его применимым.",
foreground='#1a6e1a', wraplength=920, justify=tk.LEFT).pack(anchor=tk.W, pady=(0, 8))
def _open_calc_dialog(num, name, var, combo):
"""Авторасчёт показателя 8 или 9 по «Рекомендациям по оценке показателей
критериев экономической значимости» ФСТЭК (2021). Показывает расчёт
и предлагает категорию как рекомендацию."""
import re as _re2
from .variables import (CRITERION_QUANT_THRESHOLDS, CRITERION_CHOICE_INDICATORS,
CRITERION_QUANT_UNITS, _calc_criterion_other,
_calc_criterion_13_opk, _criterion_10_cat)
# Сфера ОПК — для показателей 8/13а/13б используются формулы ПП РФ № 796
_sphere_opk = False
try:
_sphere_opk = (self.is_sphere_combo.get().strip() == 'Оборонная промышленность')
except Exception:
_sphere_opk = False
def _num(s):
# «121 118 270 руб», «1 000», «906 779 руб», «2 212.5» → число
s = str(s or '').strip()
if not s:
return None
s = s.replace('\u00a0', ' ').replace(' ', '')\
.replace('руб', '').replace('₽', '').replace('р.', '').replace(',', '.')
try:
return float(s)
except Exception:
return None
# Данные организации (прибыль/налог) из модели
profit = getattr(self.model, 'profit', '') or ''
profit_tax = getattr(self.model, 'profit_tax', '') or ''
# Родитель — окно ПП 127 (dialog), а не главное окно;
# grab_set + lift — чтобы окно не уходило на задний план
w = tk.Toplevel(dialog)
w.title(f"🧮 Авторасчёт показателя {num}{name[:60]}")
w.geometry("980x820")
w.minsize(760, 560)
w.resizable(True, True)
w.transient(dialog)
w.lift()
w.focus_force()
# Внешняя рамка: тело со скроллом + кнопки снизу (всегда видны)
outer = ttk.Frame(w, padding=10)
outer.pack(fill=tk.BOTH, expand=True)
canvas = tk.Canvas(outer, highlightthickness=0)
scrollbar = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
frm = ttk.Frame(canvas)
frm.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=frm, 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)
if num in ('13а', '13б') and _sphere_opk:
# ОПК: 13а — V = (1(V_maxV_инц)/V_ГОЗ)×100%, 13б — T = (1(t_норм/t_ненорм))×100%
# (ПП РФ № 796 от 27.06.2026, п. 34, 38)
ttk.Label(frm, text=f"Показатель {num} — по отраслевым особенностям ОПК (ПП РФ № 796)",
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
if num == '13а':
ttk.Label(frm, text="Формула: V = (1 (V_max V_инц) / V_ГОЗ) × 100%, где\n"
"V_max — максимальный объём продукции ГОЗ при максимальной скорости выпуска;\n"
"V_инц — объём, поставленный во время устранения компьютерного инцидента;\n"
"V_ГОЗ — полный объём продукции по ГОЗ.",
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
def _row(label, key, default=''):
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
e = ttk.Entry(frm, width=80)
e.insert(0, str(default or ''))
e.pack(fill=tk.X)
return e
_ci13a = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
e_vmax = _row("V_max — максимальный объём продукции ГОЗ (ед.):", 'vmax', _ci13a.get('13a_vmax', ''))
e_vinc = _row("V_инц — объём продукции, поставленный во время устранения инцидента (ед.):", 'vinc', _ci13a.get('13a_vinc', ''))
e_vgoz = _row("V_ГОЗ — полный объём продукции по ГОЗ (ед.):", 'vgoz', _ci13a.get('13a_vgoz', ''))
else:
ttk.Label(frm, text="Формула: T = (1 − (t_норм / t_ненорм)) × 100%, где\n"
"t_норм — сумма времени операций по производству единицы продукции ГОЗ\n"
"при штатном функционировании объекта КИИ;\n"
"t_ненорм — сумма времени тех же операций при ненормальном функционировании.",
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
def _row(label, key, default=''):
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
e = ttk.Entry(frm, width=80)
e.insert(0, str(default or ''))
e.pack(fill=tk.X)
return e
_ci13b = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
e_tnorm = _row("t_норм — время операций при штатном функционировании (час):", 'tnorm', _ci13b.get('13b_tnorm', ''))
e_tnon = _row("t_ненорм — время операций при ненормальном функционировании (час):", 'tnon', _ci13b.get('13b_tnonorm', ''))
result_frame = ttk.Frame(frm)
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
result = tk.Text(result_frame, height=10, wrap=tk.WORD, state=tk.NORMAL)
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
result.configure(yscrollcommand=result_scroll.set)
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
elif (num in CRITERION_QUANT_THRESHOLDS or num in CRITERION_CHOICE_INDICATORS):
# Остальные показатели (1–7, 11–14): количественные — ввод значения,
# качественные — выбор варианта (III/II/I) из справочника ПП-127
is_choice = num in CRITERION_CHOICE_INDICATORS
ttk.Label(frm, text=f"Показатель {num} — по порогам ПП-127",
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
if is_choice:
ttk.Label(frm, text="Выберите один из вариантов значений (соответствует III/II/I категории):",
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
else:
_unit = CRITERION_QUANT_UNITS.get(num, '')
ttk.Label(frm, text=f"Введите значение показателя ({_unit}): категория определяется "
"по пороговым значениям ПП-127.",
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
def _row(label, key, default=''):
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
e = ttk.Entry(frm, width=80)
e.insert(0, str(default or ''))
e.pack(fill=tk.X)
return e
if is_choice:
# Варианты значений для качественного показателя (из criteria_127.json)
_opts = []
for _g in (criteria if isinstance(criteria, list) else []):
for _ind in _g.get('indicators', []):
if str(_ind.get('num', '')) == num:
_vals = _ind.get('values', {}) or {}
_opts = [str(v) for v in _vals.values() if str(v).strip()]
break
e_choice = ttk.Combobox(frm, width=90, state='readonly', values=_opts)
e_choice.pack(fill=tk.X, pady=4)
else:
e_value = _row(f"Значение показателя ({_unit}):", 'value', '')
result_frame = ttk.Frame(frm)
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
result = tk.Text(result_frame, height=10, wrap=tk.WORD, state=tk.NORMAL)
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
result.configure(yscrollcommand=result_scroll.set)
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
elif num == '10' or str(num).startswith('10.'):
# Показатель 10 — прекращение финансовых операций (банковская сфера)
is_base = str(num) == '10'
if is_base:
ttk.Label(frm, text="Показатель 10 — прекращение (нарушение) проведения клиентами операций "
"по переводу денежных средств",
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
ttk.Label(frm, text="Применим для: системно значимых кредитных организаций, операторов услуг "
"платёжной инфраструктуры значимых платёжных систем, операторов платформы "
"цифрового рубля и др.\n"
"Формула: №10 = операции за год (млн ед.) / календарные дни в году "
"(среднедневное количество операций).\n"
"Пороги: ≤ 70 млн/день → III; > 70 и ≤ 120 → II; > 120 → I.",
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
else:
ttk.Label(frm, text=f"Показатель {num} — прекращение (нарушение) проведения операций "
"(подпоказатель 10.x)",
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
ttk.Label(frm, text="Значение показателя вводится в единицах, установленных ПП-127 "
"(трлн руб., тыс. шт., млрд руб., количество договоров, млн ед.); "
"категория определяется по порогам ПП-127.",
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
def _row(label, key, default=''):
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
e = ttk.Entry(frm, width=80)
e.insert(0, str(default or ''))
e.pack(fill=tk.X)
return e
_ops = getattr(self.model, 'ops_count', '') or ''
_days = getattr(self.model, 'calendar_days', '') or '365'
_v10 = getattr(self.model, 'criterion10_value', '') or ''
if is_base:
e_ops = _row("Количество операций за прошедший год (млн ед.):", 'ops', _ops)
e_days = _row("Количество календарных дней в году (по умолчанию 365):", 'days', _days)
else:
e_val10 = _row("Значение показателя (в единицах ПП-127):", 'val10', _v10)
result_frame = ttk.Frame(frm)
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
result = tk.Text(result_frame, height=12, wrap=tk.WORD, state=tk.NORMAL)
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
result.configure(yscrollcommand=result_scroll.set)
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
elif num == '9':
ttk.Label(frm, text="Показатель 9 — ущерб бюджетам РФ (применим ко всем субъектам КИИ)",
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
ttk.Label(frm, text="Формула: №9 = ΔБ / Бусредн × 100%, где ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС\n"
"ΔНП = ΔДоходов × (налог на прибыль / прибыль); если ΔДоходов > прибыли — весь налог.",
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
def _row(label, key, default=''):
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
e = ttk.Entry(frm, width=80)
e.insert(0, str(default or ''))
e.pack(fill=tk.X)
return e
e_profit = _row("Прибыль субъекта КИИ за прошедший год (руб.):", 'profit', profit)
e_tax = _row("Налог на прибыль за прошедший год (руб.):", 'tax', profit_tax)
# ΔДоходов: если есть балансовая стоимость объекта КИИ — предзаполняем её
_bv9 = getattr(isys, 'balance_value', '') or ''
_bvn9 = _num(_bv9)
e_dincome = _row("Снижение уровня доходов ΔДоходов (руб., наихудший сценарий):", 'dincome',
f"{_bvn9:,.0f}" if _bvn9 else '')
if _bvn9:
ttk.Label(frm, text="⚠️ ΔДоходов — подставлена балансовая стоимость объекта КИИ "
f"({_bvn9:,.0f} руб.); можно поправить.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
else:
ttk.Label(frm, text="⚠️ ΔДоходов — не заполнено: введите вручную или заполните "
"балансовую стоимость объекта КИИ во вкладке «Объект».",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
e_budget = _row("Прогнозируемый годовой доход федерального бюджета, усреднённый за планируемый трёхлетний период — Бусредн (руб.):", 'budget',
f'{DEFAULT_BUDGET_AVG:.0f}')
ttk.Label(frm, text="⚠️ Бусредн — прогнозируемый годовой доход федерального бюджета, "
"усреднённый за планируемый трёхлетний период (данные Минфина; "
"подставлено значение по умолчанию, при необходимости уточните).",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
ttk.Label(frm, text="Прочие составляющие ΔБ (если применимы, иначе 0):",
font=('', 9, 'bold')).pack(anchor=tk.W, pady=(8, 0))
e_ndpi = _row("Снижение выплат от налога на добычу ПИ ΔНДПИ (руб.):", 'ndpi', '0')
e_div = _row("Снижение выплат от дивидендов ΔДИВ (руб.):", 'div', '0')
e_vso = _row("Снижение выплат сторонних организаций ΔВСО (руб.):", 'vso', '0')
e_ns = _row("Снижение сборов ΔНС (руб.):", 'ns', '0')
result_frame = ttk.Frame(frm)
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
result = tk.Text(result_frame, height=12, wrap=tk.WORD, state=tk.NORMAL)
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
result.configure(yscrollcommand=result_scroll.set)
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
elif num == '8' and _sphere_opk:
# ОПК: U_8 = U_у8 / R_год × 100%, U_у8 = C + A + X (ПП РФ № 796, п. 1820)
ttk.Label(frm, text="Показатель 8 — ущерб субъекту КИИ (отраслевые особенности ОПК, ПП РФ № 796)",
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
ttk.Label(frm, text="Формула: U_8 = U_у8 / R_год × 100%, где U_у8 = C + A + X\n"
"C — суммарные затраты на объект КИИ;\n"
"A — затраты на возмещение ущерба потерпевшим (если ущерба нет, A = 0);\n"
"X — ущерб от ранее произошедших компьютерных инцидентов (если не было, X = 0);\n"
"R_год — усреднённый объём годового дохода за прошедший 5-летний период.",
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
def _row(label, key, default=''):
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
e = ttk.Entry(frm, width=80)
e.insert(0, str(default or ''))
e.pack(fill=tk.X)
return e
_ci8 = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
_bv8 = getattr(isys, 'balance_value', '') or ''
_bvn8 = _num(_bv8)
_c_def = _num(_ci8.get('8_c', '') or '') or _bvn8
e_c = _row("C — суммарные затраты на объект КИИ (руб.):", 'c',
f"{_c_def:,.0f}" if _c_def else '')
if _bvn8 and not _num(_ci8.get('8_c', '') or ''):
ttk.Label(frm, text="⚠️ C — подставлена балансовая стоимость объекта КИИ "
f"({_bvn8:,.0f} руб.); можно поправить.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
e_a = _row("A — затраты на возмещение ущерба потерпевшим (руб., по умолчанию 0):", 'a',
_ci8.get('8_a', ''))
e_x = _row("X — ущерб от ранее произошедших компьютерных инцидентов (руб., по умолчанию 0):", 'x',
_ci8.get('8_x', ''))
_incs_raw8 = [getattr(self.model, f'income_year_{i}', '') or '' for i in range(1, 6)]
_incs8 = [_num(x) for x in _incs_raw8]
_incs8_f = [x for x in _incs8 if x is not None]
_inc58 = _num(getattr(self.model, 'income_5y', '') or '')
if _inc58:
_d_avg8 = _inc58 / 5.0
elif _incs8_f:
_d_avg8 = sum(_incs8_f) / len(_incs8_f)
else:
_d_avg8 = None
e_r = _row("R_год — усреднённый годовой доход за 5 лет (руб.):", 'r',
f"{_d_avg8:,.0f}" if _d_avg8 else '')
ttk.Label(frm, text="⚠️ R_год — среднее по заполненным годам; ПП-127 требует усреднение "
"за прошедший пятилетний период.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
result_frame = ttk.Frame(frm)
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
result = tk.Text(result_frame, height=12, wrap=tk.WORD, state=tk.NORMAL)
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
result.configure(yscrollcommand=result_scroll.set)
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
else:
ttk.Label(frm, text="Показатель 8 — ущерб субъекту КИИ",
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
ttk.Label(frm, text="Применим только для: госкорпорации, ГУП, госкомпании, стратегического АО/предприятия "
"(Указ Президента № 1009).\nФормула: №8 = ΔДоходов / Дусредн × 100%.",
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
def _row(label, key, default=''):
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
e = ttk.Entry(frm, width=80)
e.insert(0, str(default or ''))
e.pack(fill=tk.X)
return e
e_profit = e_tax = None
# Доходы за 5 лет — из данных организации (поле «Доход за 5 лет (суммарно)»,
# иначе — обратная совместимость по годам)
_incs_raw = [getattr(self.model, f'income_year_{i}', '') or '' for i in range(1, 6)]
_incs = [_num(x) for x in _incs_raw]
_incs_filled = [x for x in _incs if x is not None]
_inc5 = _num(getattr(self.model, 'income_5y', '') or '')
if _inc5:
_d_avg = _inc5 / 5.0
_n_years = 5
elif _incs_filled:
_d_avg = sum(_incs_filled) / len(_incs_filled)
_n_years = len(_incs_filled)
else:
_d_avg = None
_n_years = 0
_d_loss = _num(getattr(self.model, 'income_loss', '') or '')
_bv8 = getattr(isys, 'balance_value', '') or ''
_bvn8 = _num(_bv8)
_dincome_def = _d_loss or _bvn8
_dincome_est = False
if not _dincome_def and _d_avg:
# Фолбэк: ущерб не заполнен → оценка «доход за год × 5» (наихудший сценарий)
_dincome_def = _d_avg * 5
_dincome_est = True
e_dincome = _row("Снижение уровня дохода от нарушения критических процессов (руб.):",
'dincome', f"{_dincome_def:,.0f}" if _dincome_def else '')
if _d_loss:
ttk.Label(frm, text="⚠️ ΔДоходов — подставлено из вкладки «Организация» "
f"({_d_loss:,.0f} руб.); можно поправить.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
elif _bvn8:
ttk.Label(frm, text="⚠️ ΔДоходов — подставлена балансовая стоимость объекта КИИ "
f"({_bvn8:,.0f} руб.); можно поправить.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
elif _dincome_est:
ttk.Label(frm, text="⚠️ ΔДоходов — не заполнено, принята оценка «доход за год × 5» "
f"({_d_avg:,.0f} × 5 = {_dincome_def:,.0f} руб., наихудший сценарий); можно поправить.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
else:
ttk.Label(frm, text="⚠️ ΔДоходов — не заполнено: введите вручную или заполните "
"«Снижение уровня дохода» во вкладке «Организация».",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
e_budget = _row("Доходы субъекта КИИ, усреднённые за 5 лет (руб.):",
'budget', f"{_d_avg:,.0f}" if _d_avg else '')
_n_years = len(_incs_filled)
ttk.Label(frm, text=f"⚠️ Доходы за 5 лет — среднее по заполненным годам ({_n_years} из 5). "
"ПП-127 требует усреднение за прошедший пятилетний период: "
"при неполных данных результат методологически некорректен — заполните все 5 лет.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
result_frame = ttk.Frame(frm)
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
result = tk.Text(result_frame, height=12, wrap=tk.WORD, state=tk.NORMAL)
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
result.configure(yscrollcommand=result_scroll.set)
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
def _calc():
lines = []
if num in ('13а', '13б') and _sphere_opk:
# Сохраняем введённые составляющие формулы в criterion_input (для шаблона)
if isys:
_ci = dict(getattr(isys, 'criterion_input', None) or {})
if num == '13а':
_ci['13a_vmax'] = e_vmax.get().strip()
_ci['13a_vinc'] = e_vinc.get().strip()
_ci['13a_vgoz'] = e_vgoz.get().strip()
else:
_ci['13b_tnorm'] = e_tnorm.get().strip()
_ci['13b_tnonorm'] = e_tnon.get().strip()
isys.criterion_input = _ci
text, cat = _calc_criterion_13_opk(self.model, isys, num)
if text.startswith('Заполните'):
result.delete('1.0', tk.END)
result.insert('1.0', text)
return
lines.append(text)
if cat:
lines.append(f"Категория значимости: {cat} (по порогам ПП-127)")
else:
lines.append("Категория значимости: — (значение ниже порога III)")
elif (num in CRITERION_QUANT_THRESHOLDS or num in CRITERION_CHOICE_INDICATORS):
is_choice = num in CRITERION_CHOICE_INDICATORS
if is_choice:
choice = e_choice.get().strip()
if not choice:
result.delete('1.0', tk.END)
result.insert('1.0', "Выберите вариант значения показателя.")
return
text, cat = _calc_criterion_other(self.model, isys, num, choice=choice)
else:
val = _num(e_value.get())
if val is None:
result.delete('1.0', tk.END)
result.insert('1.0', "Введите числовое значение показателя.")
return
text, cat = _calc_criterion_other(self.model, isys, num, value=val)
lines.append(text)
if cat:
lines.append(f"Категория значимости: {cat} (по порогам ПП-127)")
else:
lines.append("Категория значимости: — (значение ниже порога III)")
elif num == '10' or str(num).startswith('10.'):
if str(num) == '10':
ops = _num(e_ops.get())
days = _num(e_days.get())
if days is None or days <= 0:
days = 365
if ops is None:
result.delete('1.0', tk.END)
result.insert('1.0', "Заполните количество операций за год (млн ед.).")
return
val = ops / days
lines.append(f"Количество операций за прошедший год: {ops:,.0f} млн ед.")
lines.append(f"Количество календарных дней в году: {days:.0f}")
lines.append(f"№10 = операции / дни = {ops:,.0f} / {days:.0f} = {val:.6f} млн ед./день")
else:
val = _num(e_val10.get())
if val is None:
result.delete('1.0', tk.END)
result.insert('1.0', "Заполните значение показателя.")
return
lines.append(f"Значение показателя {num}: {val:,.6f}")
cat = _criterion_10_cat(num, val)
if cat:
lines.append(f"Категория значимости: {cat} (по порогам ПП-127)")
else:
lines.append("Категория значимости: — (значение ниже порога III)")
elif num == '8' and _sphere_opk:
c = _num(e_c.get())
a = _num(e_a.get()) or 0
x = _num(e_x.get()) or 0
r = _num(e_r.get())
if c is None or r is None or r <= 0:
result.delete('1.0', tk.END)
result.insert('1.0', "Заполните C (суммарные затраты на объект) и R_год (доходы за 5 лет).")
return
if isys:
_ci = dict(getattr(isys, 'criterion_input', None) or {})
_ci['8_c'] = str(e_c.get().strip())
_ci['8_a'] = str(e_a.get().strip())
_ci['8_x'] = str(e_x.get().strip())
isys.criterion_input = _ci
u_y8 = c + a + x
val = u_y8 / r * 100
lines.append(f"Суммарные затраты на объект КИИ: C = {c:,.0f} руб.")
lines.append(f"Затраты на возмещение ущерба потерпевшим: A = {a:,.0f} руб.")
lines.append(f"Ущерб от ранее произошедших инцидентов: X = {x:,.0f} руб.")
lines.append(f"U_у8 = C + A + X = {c:,.0f} + {a:,.0f} + {x:,.0f} = {u_y8:,.0f} руб.")
lines.append(f"Усреднённый годовой доход за 5 лет: R_год = {r:,.0f} руб.")
lines.append(f"U_8 = U_у8 / R_год × 100% = {u_y8:,.0f} / {r:,.0f} × 100 = {val:.2f} %")
if val > 20:
cat = 'I'
elif val > 10:
cat = 'II'
elif val >= 1:
cat = 'III'
else:
cat = None
if cat:
lines.append(f"Категория значимости: {cat} (более 20% → I; 10-20% → II; 1-10% → III)")
else:
lines.append("Значение менее 1% — ни одна категория не присваивается («—»).")
elif num == '8':
d = _num(e_dincome.get())
b = _num(e_budget.get())
if d is None or b is None or b <= 0:
result.delete('1.0', tk.END)
result.insert('1.0', "Заполните оба поля (ущерб и доходы за 5 лет).")
return
val = d / b * 100
lines.append(f"Снижение уровня дохода от нарушения критических процессов: {d:,.0f} руб.")
lines.append(f"Доходы субъекта КИИ, усреднённые за 5 лет: {b:,.0f} руб.")
lines.append(f"№8 = ущерб / доходы за 5 лет × 100% = {d:,.0f} / {b:,.0f} × 100 = {val:.2f} %")
if val > 20:
cat = 'I'
elif val > 10:
cat = 'II'
elif val >= 1:
cat = 'III'
else:
cat = None
if cat:
lines.append(f"Категория значимости: {cat} (более 20% → I; 10-20% → II; 1-10% → III)")
else:
lines.append("Значение менее 1% — ни одна категория не присваивается («—»).")
else:
p = _num(e_profit.get())
t = _num(e_tax.get())
d = _num(e_dincome.get())
b = _num(e_budget.get())
if d is None or b is None or b <= 0:
result.delete('1.0', tk.END)
result.insert('1.0', "Заполните ΔДоходов и Бусредн (прибыль/налог — из данных организации).")
return
if p and t is not None and p > 0:
knp = t / p
dnp = d * knp
if d > p:
dnp = t
lines.append(f"Коэффициент КНП = налог на прибыль / прибыль = {t:,.0f} / {p:,.0f} = {knp:.4f}")
lines.append(f"ΔНП = ΔДоходов × КНП = {d:,.0f} × {knp:.4f} = {dnp:,.1f} руб."
+ (" (ΔДоходов > прибыли → весь налог)" if d > p else ""))
else:
dnp = 0
lines.append("Прибыль/налог не заполнены — ΔНП принят равным 0 (расчёт неполный).")
ndpi = _num(e_ndpi.get()) or 0
div = _num(e_div.get()) or 0
vso = _num(e_vso.get()) or 0
ns = _num(e_ns.get()) or 0
db = dnp + ndpi + div + vso + ns
lines.append(f"ΔНДПИ = {ndpi:,.0f} руб., ΔДИВ = {div:,.0f} руб., ΔВСО = {vso:,.0f} руб., ΔНС = {ns:,.0f} руб.")
lines.append(f"ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС = {db:,.1f} руб.")
lines.append(f"Бусредн (прогноз годового дохода федбюджета, усреднённый за планируемый трёхлетний период): {b:,.0f} руб.")
val = db / b * 100
lines.append(f"№9 = ΔБ / Бусредн × 100% = {db:,.1f} / {b:,.0f} × 100 = {val:.6f} %")
if val > 0.001:
cat = 'I'
elif val > 0.0006:
cat = 'II'
elif val > 0.0003:
cat = 'III'
else:
cat = None
if cat:
lines.append(f"Категория значимости: {cat} (более 0,001% → I; 0,0006-0,001% → II; 0,0003-0,0006% → III)")
else:
lines.append("Значение ≤ 0,0003% — ни одна категория не присваивается («—»).")
result.delete('1.0', tk.END)
result.insert('1.0', '\n'.join(lines))
def _apply():
_calc()
txt = result.get('1.0', 'end-1c').strip()
# Если расчёт не удался (ошибка «Заполните…») — не применяем и не пишем в обоснование
if txt.startswith('Заполните'):
return
# Сохраняем введённое значение показателя в модель (для автогенерации шаблона)
if (num in CRITERION_QUANT_THRESHOLDS or num in CRITERION_CHOICE_INDICATORS) and isys:
try:
if num in CRITERION_CHOICE_INDICATORS:
_v = e_choice.get().strip()
else:
_v = e_value.get().strip()
if _v:
_ci = dict(getattr(isys, 'criterion_input', None) or {})
_ci[num] = _v
isys.criterion_input = _ci
except Exception:
pass
lines = txt.split('\n')
cat_line = next((l for l in lines if l.startswith('Категория значимости:')), '')
cat = None
m = _re2.search(r'Категория значимости:\s*(I{1,3})\b', cat_line)
if m:
cat = m.group(1)
if cat:
opts = list(combo['values'])
target = next((o for o in opts if o.startswith(f"{cat} категория")), None)
if target:
var.set(target)
prefix = f"Показатель {num}. {name}{cat} категория."
cur = justify_txt.get('1.0', 'end-1c').strip()
if prefix not in cur:
_append_justify(prefix, txt)
else:
var.set('—')
prefix = f"Показатель {num}. {name} — применим, категория не присвоена (—)."
cur = justify_txt.get('1.0', 'end-1c').strip()
if prefix not in cur:
_append_justify(prefix, txt)
w.grab_release()
w.destroy()
# Кнопки — вне скролла, всегда видны
btns = ttk.Frame(outer)
btns.pack(fill=tk.X, pady=(8, 0))
ttk.Button(btns, text="🧮 Рассчитать", command=_calc).pack(side=tk.LEFT, padx=2)
ttk.Button(btns, text="✔️ Применить категорию", command=_apply).pack(side=tk.LEFT, padx=2)
ttk.Button(btns, text="Отмена",
command=lambda: (w.grab_release(), w.destroy())).pack(side=tk.LEFT, padx=2)
# Захват фокуса — окно не уходит на задний план
try:
w.grab_set()
except Exception:
pass
w.lift()
rows = [] # (num, name, values{III:..,II:..,I:..}, var, applicable)
_combos = {} # num -> комбобокс (для автозаполнения)
import re as _re
for crit in criteria:
grp = ttk.LabelFrame(body, text=crit.get('criteria', ''), padding=6)
grp.pack(fill=tk.X, pady=4)
for ind in crit.get('indicators', []):
num = ind.get('num', '')
name = ind.get('name', '')
values = ind.get('values', {})
# Группа показателя: '1'→'1', '2а'→'2', '10.1'→'10', '13б'→'13'
m = _re.match(r'(\d+)', num)
grp_num = m.group(1) if m else num
applicable_here = applicable is None or grp_num in applicable
row = ttk.Frame(grp)
row.pack(fill=tk.X, pady=2)
# Номер показателя
lbl_num = ttk.Label(row, text=num, width=4, font=('Arial', 9, 'bold'))
lbl_num.pack(side=tk.LEFT)
# Текст показателя
lbl_name = ttk.Label(row, text=name, wraplength=640, justify=tk.LEFT)
lbl_name.pack(side=tk.LEFT)
# Выбор значения:
# - применимый показатель: пусто / «—» (применим, категория не присвоена) /
# «не применим» (показатель не применим) / III / II / I
# - неприменимый к сфере: только «не применим» (заблокирован, авто-обоснование из словаря)
var = tk.StringVar(value='')
full_options = ['', '—', 'не применим']
for cat in ('III', 'II', 'I'):
v = values.get(cat, '')
if v:
full_options.append(f"{cat} категория: {v}")
options = full_options
combo = ttk.Combobox(row, textvariable=var, state='readonly', width=34,
values=options)
combo.pack(side=tk.RIGHT, padx=6)
_combos[num] = combo
# Кнопка «🧮» — авторасчёт показателя:
# 8/9/10 — экономические формулы; остальные (1–7, 11–14) — по порогам ПП-127
if (grp_num in ('8', '9', '10')
or num in CRITERION_QUANT_THRESHOLDS
or num in CRITERION_CHOICE_INDICATORS):
ttk.Button(row, text='🧮', width=3,
command=lambda n=num, nm=name, v=var, c=combo:
_open_calc_dialog(n, nm, v, c)
).pack(side=tk.RIGHT, padx=(2, 0))
def _on_pick(_e, _num=num, _var=var, _name=name):
# При выборе «не применим» — автоподстановка обоснования из словаря сферы
if _var.get().strip() == 'не применим':
txt = _just_text(_num)
prefix = f"Показатель {_num}. {_name} — не применим."
if txt:
cur = justify_txt.get('1.0', 'end-1c').strip()
if prefix not in cur:
_append_justify(prefix, txt)
combo.bind('<<ComboboxSelected>>', _on_pick)
if not applicable_here:
# Показатель не применим для сферы объекта:
# в списке только «не применим», выбран автоматически и заблокирован.
# Кнопка «🔓» — разблокировать конкретный показатель (вернуть категории и «—»)
options = ['не применим']
combo.config(values=options)
var.set('не применим')
combo.config(state='disabled')
lbl_num.config(foreground='gray')
lbl_name.config(foreground='gray')
lbl_name.config(text=name + ' (не применим для сферы)')
btn_unlock = ttk.Button(row, text='🔓', width=3,
command=lambda c=combo, v=var,
ln=lbl_num, lname=lbl_name,
b=None, nm=name, fo=full_options:
_unlock(c, v, ln, lname, nm, b, fo))
btn_unlock.pack(side=tk.RIGHT, padx=(2, 0))
btn_unlock.config(command=lambda c=combo, v=var, ln=lbl_num,
lname=lbl_name, b=btn_unlock, nm=name, fo=full_options:
_unlock(c, v, ln, lname, nm, b, fo))
rows.append((num, name, values, var, applicable_here))
# Обоснование
ttk.Label(body, text="Обоснование полученных значений:", font=('Arial', 9, 'bold')).pack(
anchor=tk.W, pady=(10, 2))
justify_txt = tk.Text(body, width=110, height=5, wrap=tk.WORD)
justify_txt.pack(fill=tk.X, pady=(0, 6))
# Висячий отступ: заголовок «Показатель N. …» — первая строка без отступа,
# перенесённые строки — с отступом (чтобы продолжение не выглядело новым пунктом);
# тело расчёта — с отступом слева; между показателями — пустая строка.
justify_txt.tag_configure('just_head', lmargin1=0, lmargin2=30, spacing1=6, spacing3=2)
justify_txt.tag_configure('just_body', lmargin1=30, lmargin2=30, spacing3=4)
def _append_justify(prefix, body):
"""Добавить запись в обоснование: заголовок с висячим отступом + тело."""
cur = justify_txt.get('1.0', 'end-1c').strip()
if cur:
justify_txt.insert('end', '\n\n')
justify_txt.insert('end', str(prefix), 'just_head')
if body:
justify_txt.insert('end', '\n' + str(body), 'just_body')
if hasattr(self, 'is_criteria_justification_listbox') and self.is_criteria_justification_listbox.size():
justify_txt.insert('1.0', '\n'.join(
self.is_criteria_justification_listbox.get(i)
for i in range(self.is_criteria_justification_listbox.size())))
result_lbl = ttk.Label(body, text="Категория значимости: —", font=('Arial', 10, 'bold'))
result_lbl.pack(anchor=tk.W, pady=(6, 0))
def _selected():
"""Список выбранных: [(num, name, категория, значение)].
«не применим» возвращается как ('—', 'не применим') и не влияет на категорию,
но сохраняется в список показателей и в обоснование.
"""
out = []
for num, name, values, var, applicable_here in rows:
s = var.get().strip()
if not s:
continue
if s == '—':
out.append((num, name, '—', 'прочерк')) # применим, категория не присвоена
continue
if s == 'не применим':
out.append((num, name, '—', 'не применим'))
continue
cat = s.split(' ')[0] # III/II/I
val = s.split(': ', 1)[1] if ': ' in s else ''
out.append((num, name, cat, val))
return out
def _calc_category(selected):
"""Категория по наивысшей из выбранных."""
cats = [c for _, _, c, _ in selected]
if 'I' in cats:
return '1'
if 'II' in cats:
return '2'
if 'III' in cats:
return '3'
return 'без категории'
def _calc():
selected = _selected()
# «не применим» не влияет на категорию — считаем только реально выбранные категории
chosen = [x for x in selected if x[2] != '—']
if not chosen:
if applicable is not None and not any(a for _, _, _, _, a in rows):
result_lbl.config(text="Категория значимости: без категории (для этой сферы нет применимых показателей)")
else:
result_lbl.config(text="Категория значимости: — (ничего не выбрано)")
return
cat = _calc_category(chosen)
result_lbl.config(text=f"Категория значимости: {cat} (выбрано показателей: {len(chosen)})")
def _save():
selected = _selected()
# Заполняем список показателей (включая «—» и «не применим»)
lines = []
for num, name, cat, val in selected:
if cat == '—' and val == 'не применим':
lines.append(f"Показатель {num}. {name} — не применим")
elif cat == '—':
lines.append(f"Показатель {num}. {name} — применим, категория не присвоена (—)")
else:
lines.append(f"Показатель {num}. {name}{cat} категория: {val}")
if hasattr(self, 'is_criteria_values_listbox'):
self.is_criteria_values_listbox.delete(0, tk.END)
for line in lines:
self.is_criteria_values_listbox.insert(tk.END, line)
# Категория значимости — только по реально выбранным категориям
chosen = [x for x in selected if x[2] != '—']
not_applicable = [x for x in selected if x[2] == '—']
cat = _calc_category(chosen)
if hasattr(self, 'is_category_var'):
self.is_category_var.set(cat)
# Обоснование: если поле пустое — сформируем автоматически
# (по формату: категория + выбранные показатели + неприменимые с обоснованиями)
j = justify_txt.get('1.0', 'end-1c').strip()
auto_parts = []
if chosen:
auto_parts.append(
"Категория значимости объекта КИИ определена в соответствии с Правилами категорирования "
"объектов критической информационной инфраструктуры РФ (ПП РФ от 08.02.2018 № 127). "
f"Объект отнесён к {cat} категории значимости. Основания: "
+ "; ".join(f"показатель {num} ({c} категория)" for num, _, c, _ in chosen) + "."
)
for num, name, _, val in not_applicable:
if val == 'не применим':
base = f"Показатель {num}. {name} — не применим"
jtxt = _just_text(num)
if jtxt:
auto_parts.append(base + ": " + jtxt)
else:
auto_parts.append(base + ".")
else:
auto_parts.append(
f"Показатель {num}. {name} — применим, категория значимости не присвоена.")
if not_applicable and not chosen:
auto_parts.insert(0, "Категория значимости: не присвоена (показатели критериев значимости неприменимы к объекту).")
auto_just = "\n".join(auto_parts) if auto_parts else ""
if not j:
j = auto_just
if hasattr(self, 'is_criteria_justification_listbox'):
self.is_criteria_justification_listbox.delete(0, tk.END)
for line in j.split('\n'):
if line.strip():
self.is_criteria_justification_listbox.insert(tk.END, line.strip())
self.log(f"📋 Критерии значимости (ПП 127) сохранены: категория {cat}, показателей: {len(chosen)}")
dialog.destroy()
def _auto_fill_criteria():
"""⚡ Автозаполнение показателей ПП 127 по данным организации/объекта (рекомендации).
- показатели, не применимые к сфере — уже «не применим» (при открытии);
- п. 8: если организация не госкорпорация/ГУП/госкомпания/стратегическое АО
→ «не применим» с обоснованием;
- п. 9: если есть прибыль и налог на прибыль → расчёт по «Рекомендациям»
ФСТЭК 2021 (КНП → ΔНП → №9 → категория), оператор вводит ΔДоходов;
- остальные применимые показатели — оставляются оператору.
"""
def _num2(s):
s = str(s or '').strip()
if not s:
return None
s = s.replace('\u00a0', ' ').replace(' ', '')\
.replace('руб', '').replace('₽', '').replace('р.', '').replace(',', '.')
try:
return float(s)
except Exception:
return None
org = ' '.join([getattr(self.model, 'full_name', '') or '',
getattr(self.model, 'short_name', '') or '']).lower()
is_special = any(k in org for k in [
'госкорпорац', 'государственная корпорация', 'гуп', 'фгуп',
'государственное унитарное предприятие', 'государственная компания',
'росатом', 'роскосмос', 'ростех', 'вэб',
'оборонно-промышленн', 'опк', 'стратегическ'])
# Показатель 8 применим и для организаций ОПК (сфера объекта) —
# «организация оборонно-промышленного комплекса» (п. 8 ПП 127)
if not is_special:
_sphere8 = (getattr(isys, 'sphere', '') or '').lower()
if any(k in _sphere8 for k in ['оборон', 'опк']):
is_special = True
filled = []
for num, name, values, var, applicable_here in rows:
if not applicable_here:
continue # уже «не применим» (заблокирован)
m = _re.match(r'(\d+)', num)
g = m.group(1) if m else num
if g == '8':
if not is_special:
var.set('не применим')
txt = _just_text(num) or ('Субъект КИИ не относится к типам предприятий, '
'указанных в п. 8 перечня показателей критериев '
'значимости объектов КИИ.')
prefix = f"Показатель {num}. {name} — не применим."
cur = justify_txt.get('1.0', 'end-1c').strip()
if prefix not in cur:
_append_justify(prefix, txt)
filled.append(f"п. {num}: не применим (тип организации)")
elif g == '9':
profit = _num2(getattr(self.model, 'profit', '') or '')
tax = _num2(getattr(self.model, 'profit_tax', '') or '')
if profit and tax:
# ΔДоходов: если есть балансовая стоимость объекта КИИ —
# подставляем её автоматически (наихудший сценарий = объект
# выходит из строя, его надо восстанавливать/покупать заново)
_bv = getattr(isys, 'balance_value', '') or ''
_bvn = _num2(_bv)
if _bvn:
dincome = _bvn
_dincome_note = (f"ΔДоходов = балансовая стоимость объекта КИИ "
f"({dincome:,.0f} руб.) — подставлено автоматически")
else:
dincome = self._ask_amount(
dialog, "Показатель 9 — ущерб бюджетам",
"Снижение уровня доходов субъекта КИИ ΔДоходов (руб.):\n"
"(наихудший сценарий компьютерной атаки на объект)")
if dincome is None:
continue
_dincome_note = f"ΔДоходов (введено оператором) = {dincome:,.0f} руб."
budget = DEFAULT_BUDGET_AVG # Бусредн (прогноз годового дохода федбюджета, усреднённый за 3 года)
knp = tax / profit
dnp = dincome * knp
if dincome > profit:
dnp = tax
# Унификация с авторасчётом: ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС
ndpi = _num2(getattr(self.model, 'ndpi', '') or '') or 0
div = _num2(getattr(self.model, 'div', '') or '') or 0
vso = _num2(getattr(self.model, 'vso', '') or '') or 0
ns = _num2(getattr(self.model, 'ns', '') or '') or 0
db = dnp + ndpi + div + vso + ns
val = db / budget * 100
if val > 0.001:
cat = 'I'
elif val > 0.0006:
cat = 'II'
elif val > 0.0003:
cat = 'III'
else:
cat = None
combo = _combos.get(num)
if cat and combo is not None:
target = next((o for o in combo['values']
if str(o).startswith(f"{cat} категория")), None)
if target:
var.set(target)
calc_txt = (
f"{_dincome_note}\n"
f"Коэффициент КНП = налог/прибыль = {tax:,.0f} / {profit:,.0f} = {knp:.4f}\n"
f"ΔНП = ΔДоходов × КНП = {dincome:,.0f} × {knp:.4f} = {dnp:,.1f} руб.\n"
f"ΔНДПИ = {ndpi:,.0f}, ΔДИВ = {div:,.0f}, ΔВСО = {vso:,.0f}, ΔНС = {ns:,.0f} руб.\n"
f"ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС = {db:,.1f} руб.\n"
f"Бусредн (прогноз годового дохода федбюджета, усреднённый за 3 года) = {budget:,.0f} руб.\n"
f"№9 = ΔБ / Бусредн × 100% = {db:,.1f} / {budget:,.0f} × 100 = {val:.6f} %\n"
f"Категория значимости: {cat or '— (не присваивается)'}"
)
prefix = f"Показатель {num}. {name} — рекомендация по расчёту:"
cur = justify_txt.get('1.0', 'end-1c').strip()
if prefix not in cur:
_append_justify(prefix, calc_txt)
filled.append(f"п. {num}: №9 = {val:.6f}% → {cat or '—'}")
# Что осталось незаполненным (применимые, без значения)
left = []
for num, name, values, var, applicable_here in rows:
if applicable_here and not var.get().strip():
left.append(num)
_calc() # пересчитать категорию сразу
messagebox.showinfo(
"⚡ Автозаполнение ПП 127",
"Заполнено автоматически (рекомендации):\n" +
("\n".join(filled) if filled else "— нет") +
(f"\n\nОсталось заполнить вручную (нет данных для расчёта): {', '.join(left)}"
if left else "\n\nВсе применимые показатели заполнены!") +
"\n\nКатегория объекта пересчитана по наивысшей из выбранных.")
btn_frame = ttk.Frame(body)
btn_frame.pack(fill=tk.X, pady=(8, 0))
ttk.Button(btn_frame, text="⚡ Заполнить автоматически (по данным)",
command=_auto_fill_criteria).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="🧮 Рассчитать категорию", command=_calc).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="💾 Сохранить", command=_save).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="Отмена", command=dialog.destroy).pack(side=tk.LEFT, padx=2)
dialog.wait_window()
def _download_fstec_threats(self):
"""Скачать перечень угроз с сайта ФСТЭК (БДУ) и обновить список."""
try:
from .fstec_threats import download_fstec_threats, get_threats_date
except ImportError as e:
messagebox.showerror("Ошибка", f"Модуль fstec_threats не найден: {e}")
return
self.log("⬇️ Загрузка перечня угроз с bdu.fstec.ru…")
ok, msg, threats = download_fstec_threats()
if not ok:
messagebox.showerror("Загрузка угроз",
f"Не удалось загрузить:\n{msg}\n\n"
"Проверьте доступ к интернету или попробуйте позже.")
self.log(f"❌ Загрузка угроз: {msg}")
return
# Обновляем label даты актуализации
self._update_threats_date_label()
# Если список угроз уже открыт — можно показать диалог выбора
messagebox.showinfo("Загрузка угроз",
f"{msg}\n\nСписок сохранён. Теперь нажмите «📚 Из словаря» "
"в разделе «Основные угрозы БИ», чтобы выбрать нужные угрозы.")
self.log(f"✅ {msg}")
def _update_threats_date_label(self):
"""Обновить подпись даты актуализации списка угроз."""
try:
from .fstec_threats import get_threats_date
d = get_threats_date()
except Exception:
d = None
if hasattr(self, '_threats_date_label'):
if d:
self._threats_date_label.config(
text=f"Актуально на: {d}", foreground='#2e7d32')
else:
self._threats_date_label.config(
text="⚠ Список угроз ещё не скачан — нажмите «⬇️ Загрузить угрозы ФСТЭК»",
foreground='#c62828')
def _auto_fill_security_click(self):
"""⚡ Автозаполнение вкладки «Безопасность» по данным объекта (логическое).
Заполняет только пустые поля:
категория/уровень/виды нарушителя — по интернету и типу объекта;
угрозы — подбор программой (скоринг);
инциденты — из подобранных угроз;
орг./тех. меры — базовый набор + по средствам защиты объекта.
"""
if self._current_is_idx is None:
messagebox.showinfo("Автозаполнение", "Сначала выберите объект КИИ в списке слева")
return
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
try:
from .threat_rules import auto_fill_security, load_threats
threats_full = load_threats()
fill = auto_fill_security(isys, threats=threats_full)
except Exception as e:
messagebox.showerror("Ошибка", str(e))
return
if not fill:
messagebox.showinfo("Автозаполнение", "Все поля безопасности уже заполнены.")
return
# Применяем к виджетам
if 'attacker_category' in fill and hasattr(self, 'is_attacker_category_combo'):
self.is_attacker_category_combo.set(fill['attacker_category'])
if 'attacker_level' in fill and hasattr(self, 'is_attacker_level_combo'):
self.is_attacker_level_combo.set(fill['attacker_level'])
for key, attr in [('attacker_types', 'is_attacker_types_listbox'),
('threats', 'is_threats_listbox'),
('incidents', 'is_incidents_listbox'),
('org_measures', 'is_org_measures_listbox'),
('tech_measures', 'is_tech_measures_listbox')]:
if key in fill and hasattr(self, attr):
lb = getattr(self, attr)
lb.delete(0, tk.END)
lines = [ln.strip() for ln in fill[key].split('\n') if ln.strip()]
if key == 'threats':
from .threat_rules import sort_threats_by_ubi
lines = sort_threats_by_ubi(lines)
for line in lines:
lb.insert(tk.END, line)
self._collect_is() # сохранить в модель
filled = ', '.join({
'attacker_category': 'нарушитель',
'attacker_level': 'уровень',
'attacker_types': 'виды нарушителя',
'threats': 'угрозы',
'incidents': 'инциденты',
'org_measures': 'орг. меры',
'tech_measures': 'тех. меры',
}.get(k, k) for k in fill)
self.log(f"⚡ Автозаполнение выполнено: {filled}")
messagebox.showinfo("Автозаполнение",
f"Заполнено: {filled}.\n\nНезаполненные поля (если есть) требуют ручного ввода "
f"или данных из опросника.")
def _suggest_threats_program_click(self):
"""Подобрать угрозы логикой программы по характеристикам объекта."""
if self._current_is_idx is None:
messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева")
return
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
try:
from .threat_rules import suggest_threats_program
threats = suggest_threats_program(isys)
except Exception as e:
messagebox.showerror("Ошибка", str(e))
return
if not threats:
messagebox.showinfo("Угрозы", "Не удалось подобрать угрозы.\n"
"Скачайте перечень ФСТЭК: кнопка «⬇️ Загрузить угрозы ФСТЭК».")
return
lb = self.is_threats_listbox
lb.delete(0, tk.END)
from .threat_rules import sort_threats_by_ubi
for t in sort_threats_by_ubi(threats):
lb.insert(tk.END, t)
self._collect_is() # сохранить в модель — галочки в словаре будут стоять
self.log(f"⚙️ Угрозы подобраны программой: {len(threats)} шт.")
def _suggest_threats_ai_click(self):
"""Подобрать угрозы через ИИ по характеристикам объекта."""
if self._current_is_idx is None:
messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева")
return
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
from .threat_rules import _has_internet, load_threats
has_internet = _has_internet(isys)
data = {
'name': isys.name or '',
'description': isys.description or '',
'object_type': isys.object_type or '',
'sphere': isys.sphere or '',
'architecture': isys.architecture or '',
'net_category': isys.net_category or '',
'attacker_category': isys.attacker_category or '',
'has_internet': has_internet,
'summary': (
f"Объект КИИ: {isys.name or '—'}. "
f"Тип: {isys.object_type or '—'}. "
f"Сфера: {isys.sphere or '—'}. "
f"Выход в интернет: {'есть' if has_internet else 'нет'}. "
f"Категория нарушителя: {isys.attacker_category or '—'}. "
f"Сети: {isys.net_category or '—'}"
),
}
threats_full = load_threats() # свежий перечень с сайта ФСТЭК
self.log(f"🤖 Запрос к ИИ: подбор угроз для «{isys.name}» (перечень: {len(threats_full)} угроз)")
self._verify_log("Подбор угроз через ИИ…")
def _run():
try:
from .verify import suggest_threats_ai
threats, err = suggest_threats_ai(
data, settings['ai_api_key'], settings.get('ai_model'),
settings.get('ai_base_url'), threats=threats_full)
self.root.after(0, lambda: self._suggest_threats_ai_done(threats, err))
except Exception as e:
self.root.after(0, lambda: self._suggest_threats_ai_done(None, str(e)))
threading.Thread(target=_run, daemon=True).start()
def _suggest_threats_ai_done(self, threats, err):
if err:
self._verify_log(f"❌ {err}")
self.log(f"❌ Подбор угроз ИИ: {err}")
return
if not threats:
self._verify_log("❌ ИИ не вернул угрозы")
return
# Запоминаем рекомендации ИИ (уже отфильтрованы по словарю УБИ) —
# не заменяем список молча, а открываем окно подбора с галочками.
self._last_ai_threats = list(threats)
self.log(f"🤖 ИИ рекомендует угроз: {len(threats)}")
self._verify_log(f"🤖 ИИ: {len(threats)} угроз (отфильтровано по словарю УБИ)")
self._open_threats_picker()
def _ask_amount(self, parent, title, prompt, initial=''):
"""Свой диалог ввода суммы: принимает «121 118 270 руб», «1 000» и т.п.
simpledialog.askfloat НЕ умеет парсить такие строки (ошибка
«not a floating-point value please try again») — поэтому свой диалог.
Возвращает float или None (отмена).
"""
def _parse(s):
s = str(s or '').strip()
if not s:
return None
s = s.replace('\u00a0', ' ').replace(' ', '')\
.replace('руб', '').replace('₽', '').replace('р.', '').replace(',', '.')
try:
return float(s)
except Exception:
return None
result = {'val': None}
dlg = tk.Toplevel(parent)
dlg.title(title)
dlg.geometry("560x200")
dlg.transient(parent)
dlg.grab_set()
ttk.Label(dlg, text=prompt, wraplength=520, justify=tk.LEFT).pack(
padx=12, pady=(12, 6), anchor=tk.W)
e = ttk.Entry(dlg, width=42)
e.insert(0, str(initial or ''))
e.pack(padx=12, pady=4, fill=tk.X)
err = ttk.Label(dlg, text="", foreground='red')
err.pack(anchor=tk.W, padx=12)
def _ok():
v = _parse(e.get())
if v is None:
err.config(text="Введите число, например: 121 118 270")
return
result['val'] = v
dlg.grab_release()
dlg.destroy()
def _cancel():
dlg.grab_release()
dlg.destroy()
btns = ttk.Frame(dlg)
btns.pack(pady=8)
ttk.Button(btns, text="ОК", command=_ok).pack(side=tk.LEFT, padx=4)
ttk.Button(btns, text="Отмена", command=_cancel).pack(side=tk.LEFT, padx=4)
e.bind('<Return>', lambda _ev: _ok())
dlg.lift()
dlg.focus_force()
parent.wait_window(dlg)
return result['val']
def _open_threats_picker(self):
"""🎯 Окно подбора угроз: программа (синий) + ИИ (красный) + совпавшие (зелёный).
Оператор отмечает галочками нужные угрозы (как в словаре) и жмёт
«✅ Применить выбранные» — список угроз заменяется выбранными.
По умолчанию отмечены все подобранные (программа + ИИ + текущие),
оператор снимает галочки с неугодных.
"""
if self._current_is_idx is None:
messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева")
return
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
from .threat_rules import suggest_threats_program
program = suggest_threats_program(isys)
current = [str(self.is_threats_listbox.get(i)) for i in range(self.is_threats_listbox.size())]
ai = list(getattr(self, '_last_ai_threats', None) or [])
# Нормализация строк угроз: ключ сравнения — код УБИ (если есть),
# иначе — нормализованный текст.
def _ubi_code(s):
m = re.search(r'уби\.?\s*(\d+)', str(s).lower())
return m.group(1) if m else None
def _norm(s):
s = re.sub(r'^\d+[.)]?\s*', '', str(s).strip()) # ведущий номер «1. »
s = re.sub(r'\s+', ' ', s).strip() # схлопнуть пробелы
return s.lower()
def _key(s):
code = _ubi_code(s)
return ('ubi', code) if code else ('name', _norm(s))
# Объединяем все источники: key → {text, src: {program, ai, current}}
merged = {}
def _add(t, src):
if not str(t or '').strip():
return
k = _key(t)
if k not in merged:
merged[k] = {'text': str(t).strip(), 'src': set()}
merged[k]['src'].add(src)
for t in program:
_add(t, 'program')
for t in current:
_add(t, 'current')
for t in ai:
_add(t, 'ai')
items = sorted(merged.values(),
key=lambda v: (0, int(_ubi_code(v['text'])))
if _ubi_code(v['text']) else (1, _norm(v['text'])))
# Окно с чекбоксами (как выбор из словаря)
dlg = tk.Toplevel(self.root)
dlg.title(f"Подбор угроз: {isys.name or ''}")
dlg.geometry("980x700")
dlg.transient(self.root)
dlg.grab_set()
frame = ttk.Frame(dlg, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
legend = ttk.Label(frame,
text="🟦 синий — рекомендация программы | 🟥 красный — рекомендация ИИ | "
"🟩 зелёный — совпало | ⬜ белый — уже было в списке",
foreground='gray')
legend.pack(anchor=tk.W, pady=(0, 5))
# Поиск
search_var = tk.StringVar()
search_entry = ttk.Entry(frame, textvariable=search_var)
search_entry.pack(fill=tk.X, pady=(0, 5))
search_entry.focus_set()
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 = {}
widgets = {}
for v in items:
src = v['src']
has_p = 'program' in src
has_a = 'ai' in src
if has_p and has_a:
bg = '#c8e6c9' # зелёный — совпало
elif has_p:
bg = '#b3d4fc' # синий — только программа
elif has_a:
bg = '#ffd0d0' # красный — только ИИ
else:
bg = '#ffffff' # белый — только текущий список
var = tk.BooleanVar(value=True) # по умолчанию отмечено всё
cb = tk.Checkbutton(check_frame, text=v['text'], variable=var, anchor=tk.W,
bg=bg, activebackground=bg, selectcolor='white',
justify=tk.LEFT, wraplength=900)
cb.pack(fill=tk.X, pady=1)
vars[v['text']] = var
widgets[v['text']] = cb
def _apply_search(*_):
q = search_var.get().strip().lower()
for text, cb in widgets.items():
if q and q not in text.lower():
cb.pack_forget()
else:
cb.pack(fill=tk.X, pady=1)
search_var.trace_add('write', _apply_search)
btn_frame = ttk.Frame(dlg, padding=10)
btn_frame.pack(fill=tk.X)
def _select_all():
for var in vars.values():
var.set(True)
def _clear_all():
for var in vars.values():
var.set(False)
def _apply():
selected = [t for t, var in vars.items() if var.get()]
selected.sort(key=lambda s: (0, int(_ubi_code(s)))
if _ubi_code(s) else (1, _norm(s)))
lb = self.is_threats_listbox
lb.delete(0, tk.END)
for t in selected:
lb.insert(tk.END, t)
self._collect_is()
self.log(f"🎯 Применено угроз: {len(selected)}")
dlg.destroy()
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)
ttk.Button(btn_frame, text="Отмена", command=dlg.destroy).pack(side=tk.RIGHT, padx=2)
dlg.wait_window()
def _check_threats_ai_click(self):
"""🤖 Проверить текущий список угроз через ИИ (не подбирать заново).
Программа/оператор уже подобрали угрозы — ИИ проверяет, что лишнее,
чего не хватает, и даёт комментарий.
"""
if self._current_is_idx is None:
messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева")
return
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
current = [str(self.is_threats_listbox.get(i)) for i in range(self.is_threats_listbox.size())]
if not current:
messagebox.showinfo("Угрозы", "Сначала подберите угрозы (программой или ИИ).")
return
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
from .threat_rules import _has_internet
has_internet = _has_internet(isys)
data = {
'name': isys.name or '',
'description': isys.description or '',
'object_type': isys.object_type or '',
'sphere': isys.sphere or '',
'architecture': isys.architecture or '',
'net_category': isys.net_category or '',
'attacker_category': isys.attacker_category or '',
'has_internet': has_internet,
}
self.log(f"🤖 Запрос к ИИ: проверка угроз для «{isys.name}» (в списке: {len(current)})")
self._verify_log("Проверка угроз через ИИ…")
def _run():
try:
from .verify import check_threats_ai
res, err = check_threats_ai(
data, current, settings['ai_api_key'], settings.get('ai_model'),
settings.get('ai_base_url'))
self.root.after(0, lambda: self._check_threats_ai_done(res, err, isys.name))
except Exception as e:
self.root.after(0, lambda: self._check_threats_ai_done(None, str(e), isys.name))
threading.Thread(target=_run, daemon=True).start()
def _check_threats_ai_done(self, res, err, obj_name):
"""Показ результата проверки угроз ИИ."""
if err:
self.log(f"❌ Проверка угроз: {err}")
self._verify_log(f"❌ {err}")
return
if not isinstance(res, dict):
self.log("❌ ИИ не вернул результат проверки")
return
extra = res.get('лишние') or []
missing = res.get('недостающие') or []
comment = res.get('комментарий') or ''
if isinstance(extra, str):
extra = [extra]
if isinstance(missing, str):
missing = [missing]
lines = [f"🤖 Проверка угроз для «{obj_name}»:"]
lines.append(f"Всего в списке: {self.is_threats_listbox.size()}")
if extra:
lines.append(f"\n❌ Возможно ЛИШНИЕ ({len(extra)}):")
for t in extra:
lines.append(f" • {t}")
else:
lines.append("\n❌ Лишних не найдено")
if missing:
lines.append(f"\n➕ Возможно НЕ ХВАТАЕТ ({len(missing)}):")
for t in missing:
lines.append(f" • {t}")
else:
lines.append("\n➕ Недостающих не найдено")
if comment:
lines.append(f"\n💬 Комментарий: {comment}")
lines.append("\nРекомендации ИИ — оператор принимает решение.")
dlg = tk.Toplevel(self.root)
dlg.title(f"Проверка угроз ИИ — {obj_name}")
dlg.geometry("860x600")
dlg.transient(self.root)
txt = tk.Text(dlg, wrap=tk.WORD, font=('Arial', 10))
txt.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)
txt.insert('1.0', '\n'.join(lines))
txt.config(state=tk.DISABLED)
def _ubi_code(s):
m = re.search(r'уби\.?\s*(\d+)', str(s).lower())
return m.group(1) if m else None
def _add_missing():
"""Добавить недостающие (только из словаря УБИ, полные названия)."""
lb = self.is_threats_listbox
existing = {(_ubi_code(lb.get(i)) or lb.get(i).strip().lower())
for i in range(lb.size())}
added = 0
for t in missing:
code = _ubi_code(t)
key = code or str(t).strip().lower()
if key in existing:
continue
lb.insert(tk.END, t)
existing.add(key)
added += 1
if added:
# сортируем по возрастанию кода УБИ
from .threat_rules import sort_threats_by_ubi
items = [str(lb.get(i)) for i in range(lb.size())]
lb.delete(0, tk.END)
for t in sort_threats_by_ubi(items):
lb.insert(tk.END, t)
self._collect_is()
self.log(f"➕ Добавлено недостающих угроз (по словарю): {added}")
else:
self.log("➕ Недостающие уже есть в списке — ничего не добавлено")
dlg.destroy()
def _remove_extra():
"""Убрать лишние (только те, что реально есть в списке, по коду УБИ)."""
lb = self.is_threats_listbox
extra_codes = {c for c in (_ubi_code(t) for t in extra) if c}
removed = 0
for i in range(lb.size() - 1, -1, -1):
code = _ubi_code(lb.get(i))
if code and code in extra_codes:
lb.delete(i)
removed += 1
if removed:
self._collect_is()
self.log(f"➖ Убрано лишних угроз: {removed}")
else:
self.log("➖ Лишние не найдены в списке — ничего не убрано")
dlg.destroy()
btns = ttk.Frame(dlg)
btns.pack(pady=(0, 8))
if missing:
ttk.Button(btns, text="➕ Добавить недостающие (по словарю)",
command=_add_missing).pack(side=tk.LEFT, padx=4)
if extra:
ttk.Button(btns, text="➖ Убрать лишние",
command=_remove_extra).pack(side=tk.LEFT, padx=4)
ttk.Button(btns, text="Закрыть",
command=dlg.destroy).pack(side=tk.LEFT, padx=4)
self.log(f"🤖 Проверка угроз ИИ: лишних {len(extra)}, недостающих {len(missing)}")
self._verify_log('\n'.join(lines))
def _check_security_ai(self):
"""Проверка вкладки «Безопасность» через ИИ (DeepSeek/OpenAI-совместимый API)."""
if self._current_is_idx is None:
messagebox.showinfo("Безопасность", "Сначала выберите объект КИИ в списке слева")
return
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
# Собираем текущие данные безопасности объекта
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
security_data = {
'name': isys.name or '',
'description': isys.description or '',
'category': self._norm_category(isys.category) or '',
'sphere': isys.sphere or '',
'object_type': isys.object_type or '',
'architecture': isys.architecture or '',
'attacker_category': isys.attacker_category or '',
'threats': isys.threats or '',
'incidents': isys.incidents or '',
'criteria_values': isys.criteria_values or '',
'criteria_justification': isys.criteria_justification or '',
'org_measures': isys.org_measures or '',
'tech_measures': isys.tech_measures or '',
}
self.log(f"🤖 Запрос к ИИ ({settings.get('ai_model') or 'deepseek-chat'}): проверка безопасности")
self._verify_log("Проверка безопасности через ИИ…")
def _run():
try:
from .verify import check_security_ai
result, err = check_security_ai(
security_data,
settings['ai_api_key'],
settings.get('ai_model'),
settings.get('ai_base_url'),
)
self.root.after(0, lambda: self._security_ai_done(result, err))
except Exception as e:
self.root.after(0, lambda: self._security_ai_done(None, str(e)))
threading.Thread(target=_run, daemon=True).start()
def _security_ai_done(self, result, err):
"""Обработка ответа ИИ по безопасности."""
if err:
self._verify_log(f"❌ {err}")
self.log(f"❌ Проверка безопасности: {err}")
return
d = result['data']
notes = d.get('notes') or []
props = d.get('proposals') or {}
self._verify_log(f"🤖 ИИ: оценка {d.get('rating', 'warning')}, замечаний: {len(notes)}")
self.log(f"🤖 ИИ: оценка {d.get('rating', 'warning')}, замечаний: {len(notes)}")
self._show_security_ai_result(notes, props, d.get('rating', 'warning'), result.get('source', 'ИИ'))
def _show_security_ai_result(self, lines, proposals=None, rating='', source='ИИ'):
"""Окно с результатом ИИ-проверки безопасности.
Если переданы proposals — показываем каждый раздел с галочкой
и кнопкой «Применить отмеченное» (применение по галочкам, ИИ может ошибаться).
Иначе — просто текстовый результат (для остальных вызовов)."""
try:
if proposals is None:
win = tk.Toplevel(self.root)
win.title("🤖 Проверка безопасности — результат ИИ")
win.geometry("760x560")
win.transient(self.root)
txt = scrolledtext.ScrolledText(win, wrap=tk.WORD, font=('', 10), state=tk.NORMAL)
txt.pack(fill=tk.BOTH, expand=True, padx=10, pady=(10, 4))
txt.insert('1.0', '\n'.join(lines))
txt.config(state=tk.DISABLED)
ttk.Button(win, text="Закрыть", command=win.destroy).pack(pady=(0, 10))
win.grab_set()
return
# Режим с галочками: предложения ИИ по разделам безопасности
win = tk.Toplevel(self.root)
win.title("🤖 Проверка безопасности — предложения ИИ")
win.geometry("960x760")
win.transient(self.root)
frm = ttk.Frame(win, padding=8)
frm.pack(fill=tk.BOTH, expand=True)
header = f"Оценка ИИ: {rating}. Отметьте галочками, что применить в объект (ИИ может ошибаться — проверяйте)."
ttk.Label(frm, text=header, font=('Arial', 10, 'bold'),
wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
if notes_lines := [l for l in (lines or []) if l]:
ttk.Label(frm, text="Замечания:", font=('', 9, 'bold')).pack(anchor=tk.W, pady=(6, 0))
t_notes = scrolledtext.ScrolledText(frm, height=4, wrap=tk.WORD, font=('', 9))
t_notes.pack(fill=tk.X)
t_notes.insert('1.0', '\n'.join(notes_lines))
t_notes.config(state=tk.DISABLED)
sections = [
('attacker_category', 'Нарушитель (категория/уровень)'),
('threats', 'Угрозы'),
('incidents', 'Инциденты'),
('criteria_values', 'Показатели критериев'),
('criteria_justification', 'Обоснование значений'),
('org_measures', 'Орг. меры'),
('tech_measures', 'Тех. меры'),
]
entries, checks = {}, {}
for key, label in sections:
v = proposals.get(key)
if not v:
continue
row = ttk.Frame(frm)
row.pack(fill=tk.X, pady=(4, 0))
var = tk.BooleanVar(value=True)
ttk.Checkbutton(row, text=f"{label}:", variable=var).pack(side=tk.LEFT)
checks[key] = var
e = scrolledtext.ScrolledText(frm, height=2, wrap=tk.WORD, font=('', 9))
e.pack(fill=tk.X)
e.insert('1.0', v)
entries[key] = e
btn_row = ttk.Frame(frm)
btn_row.pack(fill=tk.X, pady=(8, 0))
ttk.Button(btn_row, text="✔️ Применить отмеченное",
command=lambda: self._apply_security_ai_proposals(entries, checks, win)).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="Отмена", command=win.destroy).pack(side=tk.LEFT, padx=2)
win.grab_set()
except Exception:
pass
def _apply_security_ai_proposals(self, entries, checks, win):
"""Применить отмеченные предложения ИИ к полям безопасности объекта."""
def _wanted(key):
return checks.get(key, tk.BooleanVar(value=True)).get()
def _text(key):
e = entries.get(key)
return e.get('1.0', 'end-1c').strip() if e else ''
def _fill_listbox(lb, text, sort_ubi=False):
if not lb:
return
lb.delete(0, tk.END)
import re as _re3
lines = []
for line in _re3.split(r'[\n,;]+', text or ''):
line = line.strip().lstrip('•-–— ')
if line:
lines.append(line)
if sort_ubi:
from .threat_rules import sort_threats_by_ubi
lines = sort_threats_by_ubi(lines)
for line in lines:
lb.insert(tk.END, line)
try:
if _wanted('attacker_category'):
val = _text('attacker_category')
if val and hasattr(self, 'is_attacker_category_combo'):
values = list(self.is_attacker_category_combo['values'])
exact = next((v for v in values if v.lower() == val.lower()), None)
self.is_attacker_category_combo.set(exact or val)
if _wanted('threats'):
_fill_listbox(getattr(self, 'is_threats_listbox', None), _text('threats'), sort_ubi=True)
if _wanted('incidents'):
_fill_listbox(getattr(self, 'is_incidents_listbox', None), _text('incidents'))
if _wanted('criteria_values'):
_fill_listbox(getattr(self, 'is_criteria_values_listbox', None), _text('criteria_values'))
if _wanted('criteria_justification'):
_fill_listbox(getattr(self, 'is_criteria_justification_listbox', None), _text('criteria_justification'))
if _wanted('org_measures'):
_fill_listbox(getattr(self, 'is_org_measures_listbox', None), _text('org_measures'))
if _wanted('tech_measures'):
_fill_listbox(getattr(self, 'is_tech_measures_listbox', None), _text('tech_measures'))
self._collect_is()
self.log("🤖 Предложения ИИ по безопасности применены (по галочкам)")
win.destroy()
messagebox.showinfo("Безопасность", "Отмеченные предложения ИИ применены к объекту.\nПроверьте результат и при необходимости поправьте вручную.")
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось применить: {e}")
def _fill_subject_from_company(self):
"""Заполнить поля субъекта данными организации (если объект в той же организации)."""
if self._current_is_idx is None:
messagebox.showinfo("Субъект", "Сначала выберите объект КИИ в списке слева")
return
c = self.model
if not (c.full_name or c.inn):
messagebox.showinfo("Субъект",
"Вкладка «Организация» пуста.\nСначала заполните данные организации "
"(или импортируйте опросник).")
return
def _set_entry(attr, value):
w = getattr(self, attr, None)
if w is not None:
w.delete(0, tk.END)
w.insert(0, value or '')
_set_entry('is_subject_name_entry', c.full_name or c.short_name)
_set_entry('is_subject_address_entry', c.address)
_set_entry('is_subject_inn_entry', c.inn)
_set_entry('is_subject_kpp_entry', c.kpp)
# Элемент (компонент) объекта — оставляем как есть, заполняется вручную
self.log("📋 Субъект заполнен данными организации")
messagebox.showinfo("Субъект",
"Поля «Наименование», «Адрес», «ИНН», «КПП» заполнены из вкладки «Организация».\n"
"«Элемент (компонент) объекта» заполните вручную.")
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, allow_refine=False,
display_map=None):
"""Открывает окно с чекбоксами для выбора элементов из списка.
allow_refine=True — показывать кнопку «✏️ Уточнить модель/кол-во».
display_map — dict {элемент: текст_для_отображения} (пометки, например «★»),
при применении сохраняется сам элемент (без пометки).
"""
dialog = tk.Toplevel(self.root)
dialog.title(title)
dialog.geometry("720x680")
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))
# Поиск (важно для большого перечня угроз ФСТЭК)
search_var = tk.StringVar()
search_entry = ttk.Entry(frame, textvariable=search_var)
search_entry.pack(fill=tk.X, pady=(0, 5))
search_entry.insert(0, "")
search_entry.focus_set()
# Уже выбранные элементы
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 = {}
items_map = {}
for item in items:
var = tk.BooleanVar(value=item in existing)
label = (display_map or {}).get(item, item)
cb = ttk.Checkbutton(check_frame, text=label, variable=var)
cb.pack(anchor=tk.W, pady=1)
vars[item] = var
items_map[item] = (var, cb)
if display_map:
ttk.Label(check_frame, text="★ — код подходит под 187-ФЗ (из Распоряжения ПРФ № 360-р)",
foreground='#1a6e1a').pack(anchor=tk.W, pady=(4, 0))
# Фильтр по поиску: скрываем неподходящие чекбоксы
def _apply_search(*_):
q = search_var.get().strip().lower()
for item, (var, cb) in items_map.items():
if q and q not in item.lower():
cb.pack_forget()
else:
cb.pack(anchor=tk.W, pady=1)
search_var.trace_add('write', _apply_search)
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)
def _refine_selected():
"""Для отмеченных позиций запросить модель/версию и количество."""
for item, (var, cb) in list(items_map.items()):
if not var.get():
continue
new_text = self._refine_dict_item_dialog(item)
if new_text is None or new_text == item:
continue
# Обновляем текст чекбокса и ключ в словаре
cb.config(text=new_text)
vars.pop(item, None)
items_map.pop(item, None)
vars[new_text] = var
items_map[new_text] = (var, cb)
if allow_refine:
ttk.Button(btn_frame, text="✏️ Уточнить модель/кол-во",
command=_refine_selected).pack(side=tk.LEFT, padx=2)
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 _refine_dict_item_dialog(self, base_item):
"""Окно уточнения модели и количества для позиции из словаря.
Возвращает полную строку вида «Windows 10 Pro 5 шт.» или
«Сервер Supermicro SSG-2027R -E1R24N 2 шт.» или None.
"""
import re
# Убираем из базы маркер «(уточнить …)»
base = re.sub(r'\s*\([^)]*уточн[^)]*\)', '', base_item, flags=re.I).strip() or base_item
dialog = tk.Toplevel(self.root)
dialog.title("Уточнить модель и количество")
dialog.geometry("500x180")
dialog.transient(self.root)
dialog.grab_set()
frame = ttk.Frame(dialog, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text=f"Позиция: {base}", wraplength=470,
font=('Arial', 9, 'bold')).pack(anchor=tk.W, pady=(0, 8))
ttk.Label(frame, text="Модель / версия / конфигурация (необязательно):").pack(anchor=tk.W)
model_entry = ttk.Entry(frame, width=60)
model_entry.pack(fill=tk.X, pady=(2, 6))
model_entry.focus_set()
ttk.Label(frame, text="Количество:").pack(anchor=tk.W)
count_entry = ttk.Entry(frame, width=20)
count_entry.pack(anchor=tk.W, pady=(2, 6))
result = {}
def _ok():
model = model_entry.get().strip()
count = count_entry.get().strip()
text = base
if model:
text = f"{text} {model}"
if count:
text = f"{text} {count} шт."
result['val'] = text
dialog.destroy()
def _cancel():
result['val'] = None
dialog.destroy()
btn_frame = ttk.Frame(frame)
btn_frame.pack(fill=tk.X, pady=(6, 0))
ttk.Button(btn_frame, text="✅ ОК", command=_ok).pack(side=tk.RIGHT, padx=2)
ttk.Button(btn_frame, text="Отмена", command=_cancel).pack(side=tk.RIGHT, padx=2)
dialog.bind('<Return>', lambda e: _ok())
dialog.bind('<Escape>', lambda e: _cancel())
dialog.wait_window()
return result.get('val')
def _suggest_defense_ai(self):
"""🤖 Рекомендации ИИ по средствам защиты (чего не хватает для категории)."""
if self._current_is_idx is None:
messagebox.showinfo("СЗИ", "Сначала выберите объект КИИ в списке слева")
return
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
data = {
'name': isys.name or '',
'category': isys.category or '',
'sphere': isys.sphere or '',
'object_type': isys.object_type or '',
'description': isys.description or '',
'architecture': isys.architecture or '',
'hw_sw': isys.hw_sw or '',
'os_sw': isys.os_sw or '',
'app_sw': isys.app_sw or '',
'net_category': isys.net_category or '',
'is_internet': bool(getattr(isys, 'is_internet', False)),
'threats': isys.threats or '',
'attacker_category': isys.attacker_category or '',
'defense_tools': list(self.defense_tools_listbox.get(0, tk.END)) or [],
}
self._verify_log("🤖 Запрос к ИИ: рекомендации по СЗИ…")
self.log(f"🤖 Запрос к ИИ: СЗИ для «{isys.name}» (категория {isys.category or '—'})")
def _run():
try:
from .verify import suggest_defense_ai
res, err = suggest_defense_ai(
data, settings['ai_api_key'], settings.get('ai_model'),
settings.get('ai_base_url'))
self.root.after(0, lambda: self._suggest_defense_ai_done(res, err))
except Exception as e:
self.root.after(0, lambda: self._suggest_defense_ai_done(None, str(e)))
threading.Thread(target=_run, daemon=True).start()
def _suggest_defense_ai_done(self, res, err):
if err:
self._verify_log(f"❌ {err}")
self.log(f"❌ Рекомендации ИИ по СЗИ: {err}")
return
if not res:
self._verify_log("❌ ИИ не вернул рекомендации")
return
data = res.get('data', {})
lines = [f"🤖 Рекомендации ИИ по СЗИ (категория: {self.model.information_systems[self._current_is_idx].category or '—'})", ""]
recs = data.get('рекомендации') or []
if recs:
lines.append("Чего не хватает:")
for r in recs:
if isinstance(r, dict):
lines.append(f"• {r.get('сзи', '')}{r.get('зачем', '')}")
else:
lines.append(f"• {r}")
else:
lines.append("ИИ не указал конкретных средств.")
tips = data.get('общие_советы') or []
if tips:
lines.append("")
lines.append("Общие советы:")
for t in tips:
lines.append(f"• {t}")
self._show_security_ai_result(lines)
self._verify_log("🤖 Рекомендации ИИ по СЗИ получены")
self.log(f"🤖 Рекомендации ИИ по СЗИ: {len(recs)} позиций")
def _suggest_defense_ai(self):
"""🤖 Рекомендации ИИ по средствам защиты (чего не хватает для категории)."""
if self._current_is_idx is None:
messagebox.showinfo("СЗИ", "Сначала выберите объект КИИ в списке слева")
return
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
data = {
'name': isys.name or '',
'category': isys.category or '',
'sphere': isys.sphere or '',
'object_type': isys.object_type or '',
'description': isys.description or '',
'architecture': isys.architecture or '',
'hw_sw': isys.hw_sw or '',
'os_sw': isys.os_sw or '',
'app_sw': isys.app_sw or '',
'net_category': isys.net_category or '',
'is_internet': bool(getattr(isys, 'is_internet', False)),
'threats': isys.threats or '',
'attacker_category': isys.attacker_category or '',
'defense_tools': list(self.defense_tools_listbox.get(0, tk.END)) or [],
}
self._verify_log("🤖 Запрос к ИИ: рекомендации по СЗИ…")
self.log(f"🤖 Запрос к ИИ: СЗИ для «{isys.name}» (категория {isys.category or '—'})")
def _run():
try:
from .verify import suggest_defense_ai
res, err = suggest_defense_ai(
data, settings['ai_api_key'], settings.get('ai_model'),
settings.get('ai_base_url'))
self.root.after(0, lambda: self._suggest_defense_ai_done(res, err))
except Exception as e:
self.root.after(0, lambda: self._suggest_defense_ai_done(None, str(e)))
threading.Thread(target=_run, daemon=True).start()
def _suggest_defense_ai_done(self, res, err):
if err:
self._verify_log(f"❌ {err}")
self.log(f"❌ Рекомендации ИИ по СЗИ: {err}")
return
if not res:
self._verify_log("❌ ИИ не вернул рекомендации")
return
data = res.get('data', {})
lines = [f"🤖 Рекомендации ИИ по СЗИ (категория: {self.model.information_systems[self._current_is_idx].category or '—'})", ""]
recs = data.get('рекомендации') or []
if recs:
lines.append("Чего не хватает:")
for r in recs:
if isinstance(r, dict):
lines.append(f"• {r.get('сзи', '')}{r.get('зачем', '')}")
else:
lines.append(f"• {r}")
else:
lines.append("ИИ не указал конкретных средств.")
tips = data.get('общие_советы') or []
if tips:
lines.append("")
lines.append("Общие советы:")
for t in tips:
lines.append(f"• {t}")
self._show_security_ai_result(lines)
self._verify_log("🤖 Рекомендации ИИ по СЗИ получены")
self.log(f"🤖 Рекомендации ИИ по СЗИ: {len(recs)} позиций")
def _check_criteria_ai(self):
"""🤖 Проверка категорий значимости по ПП 127 через ИИ."""
if self._current_is_idx is None:
messagebox.showinfo("Категории", "Сначала выберите объект КИИ в списке слева")
return
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
data = {
'name': isys.name or '',
'description': isys.description or '',
'category': self._norm_category(isys.category) or '',
'sphere': isys.sphere or '',
'object_type': isys.object_type or '',
'criteria_values': isys.criteria_values or '',
'criteria_justification': isys.criteria_justification or '',
}
self._verify_log("🤖 Запрос к ИИ: проверка категорий по ПП 127…")
self.log(f"🤖 Запрос к ИИ: категории ПП 127 для «{isys.name}»")
def _run():
try:
from .verify import check_criteria_ai
res, err = check_criteria_ai(
data, settings['ai_api_key'], settings.get('ai_model'),
settings.get('ai_base_url'))
self.root.after(0, lambda: self._check_criteria_ai_done(res, err))
except Exception as e:
self.root.after(0, lambda: self._check_criteria_ai_done(None, str(e)))
threading.Thread(target=_run, daemon=True).start()
def _check_criteria_ai_done(self, res, err):
if err:
self._verify_log(f"❌ {err}")
self.log(f"❌ Проверка категорий ИИ: {err}")
return
if not res:
self._verify_log("❌ ИИ не вернул результат")
return
data = res.get('data', {})
isys = self.model.information_systems[self._current_is_idx]
lines = [f"🤖 Проверка категорий по ПП № 127 — «{isys.name}»", ""]
lines.append(f"Оценка: {data.get('rating', 'warning')}")
if data.get('final_category'):
lines.append(f"Итоговая категория (по мнению ИИ): {data['final_category']}")
notes = data.get('notes') or []
if notes:
lines.append("")
lines.append("Замечания:")
for n in notes:
if isinstance(n, dict):
lines.append(f"• Показатель {n.get('показатель', '?')} ({n.get('категория', '—')}): "
f"{n.get('комментарий', '')}")
else:
lines.append(f"• {n}")
props = data.get('proposals') or {}
if props.get('criteria_values') or props.get('criteria_justification'):
lines.append("")
lines.append("Предложения ИИ (можно применить вручную):")
if props.get('criteria_values'):
lines.append(f"Показатели: {props['criteria_values']}")
if props.get('criteria_justification'):
lines.append(f"Обоснование: {props['criteria_justification']}")
self._show_security_ai_result(lines)
self._verify_log("🤖 Проверка категорий ИИ завершена")
self.log("🤖 Проверка категорий ИИ завершена")
@staticmethod
def _norm_category(cat):
"""Нормализация категории значимости: '1'/'2'/'3' → 'I'/'II'/'III'."""
cat = (cat or '').strip()
return {'1': 'I', '2': 'II', '3': 'III'}.get(cat, cat or '')
def _check_object_partial_click(self):
"""🧩 ИИ-проверка всего объекта по частям: каждый блок — отдельный запрос,
данные и ответы ИИ пишутся во временную папку (dokogen_ai_*)."""
if self._current_is_idx is None:
messagebox.showinfo("Проверка", "Сначала выберите объект КИИ в списке слева")
return
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
self._collect_company()
self._collect_is()
c = self.model
isys = self.model.information_systems[self._current_is_idx]
blocks = [
{'name': 'Реквизиты организации', 'prompt': 'check_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}},
{'name': 'Характеристики объекта КИИ', 'prompt': 'suggest_object_params',
'data': {'name': isys.name, 'description': isys.description, 'purpose': isys.purpose,
'sphere': isys.sphere, 'object_type': isys.object_type,
'architecture': isys.architecture, 'typical_object': isys.typical_object,
'net_category': isys.net_category, 'net_purpose': isys.net_purpose}},
{'name': 'Безопасность (нарушитель, угрозы, меры)', 'prompt': 'check_security',
'data': {'name': isys.name, 'description': isys.description,
'category': self._norm_category(isys.category),
'sphere': isys.sphere, 'attacker_category': isys.attacker_category,
'attacker_level': isys.attacker_level, 'attacker_types': isys.attacker_types,
'threats': isys.threats, 'incidents': isys.incidents,
'org_measures': isys.org_measures, 'tech_measures': isys.tech_measures}},
{'name': 'Категории значимости (ПП 127)', 'prompt': 'check_criteria',
'data': {'name': isys.name, 'description': isys.description,
'category': self._norm_category(isys.category),
'sphere': isys.sphere, 'object_type': isys.object_type,
'criteria_values': isys.criteria_values,
'criteria_justification': isys.criteria_justification}},
{'name': 'Средства защиты (СЗИ)', 'prompt': 'suggest_defense',
'data': {'name': isys.name, 'category': isys.category, 'sphere': isys.sphere,
'threats': isys.threats,
'defense_tools': list(self.defense_tools_listbox.get(0, tk.END)) or []}},
]
self._verify_log(f"🧩 ИИ-проверка объекта «{isys.name}» по частям ({len(blocks)} блоков)…")
self.log(f"🧩 ИИ-проверка объекта «{isys.name}» по частям: {len(blocks)} блоков")
def _run():
try:
from .verify import check_object_partial
res, err = check_object_partial(
blocks, settings['ai_api_key'], settings.get('ai_model'),
settings.get('ai_base_url'))
self.root.after(0, lambda: self._check_object_partial_done(res, err, isys.name))
except Exception as e:
self.root.after(0, lambda: self._check_object_partial_done(None, str(e), isys.name))
threading.Thread(target=_run, daemon=True).start()
def _check_object_partial_done(self, res, err, obj_name):
if err:
self._verify_log(f"❌ {err}")
self.log(f"❌ Проверка объекта по частям: {err}")
return
if not res:
self._verify_log("❌ ИИ не вернул результат")
return
lines = [f"🧩 ИИ-проверка объекта «{obj_name}» по частям", ""]
for r in res:
status = "✅" if r.get('ok') else "❌"
lines.append(f"{status} {r['name']}")
if r.get('files', {}).get('out'):
lines.append(f" ответ ИИ: {r['files']['out']}")
lines.append("")
lines.append("Полные ответы ИИ по каждому блоку — во временных файлах выше.")
lines.append("Рекомендации применяются по кнопкам разделов (по галочкам).")
self._show_security_ai_result(lines)
self._verify_log("🧩 Проверка объекта по частям завершена")
self.log("🧩 Проверка объекта по частям завершена")
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 _load_okved_dict(self):
"""Загружает словарь ОКВЭД (dictionaries/okved.json)."""
import json
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'okved.json')
try:
with open(dict_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь ОКВЭД:\n{e}")
return []
def _load_okved_187_codes(self):
"""Коды ОКВЭД, подходящие под 187-ФЗ (из Распоряжения ПРФ № 360-р)."""
import json
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'okved_187.json')
try:
with open(dict_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return []
def _is_okved_187(self, item):
"""Подходит ли строка ОКВЭД под 187-ФЗ (код совпадает/является подкодом из 360-р)."""
import re
m = re.match(r'^(\d+(?:\.\d+)*)', item or '')
if not m:
return False
code = m.group(1)
codes = getattr(self, '_okved_187_cache', None)
if codes is None:
codes = self._load_okved_187_codes()
self._okved_187_cache = codes
for c in codes:
if code == c or code.startswith(c + '.') or c.startswith(code + '.'):
return True
return False
def _load_incomes_from_dadata(self):
"""🌐 Загрузить доходы организации за 5 лет из DaData (finance_history / finance)."""
from .api_settings import load_api_settings
settings = load_api_settings()
if not settings.get('dadata_token'):
messagebox.showwarning("DaData", "Не задан API-ключ DaData.\nМеню «Файл → Настройки»")
return
inn = self._company_entries.get('inn', None)
inn_val = (inn.get().strip() if inn else '') or getattr(self.model, 'inn', '') or ''
if not inn_val:
messagebox.showinfo("DaData", "Сначала укажите ИНН организации (вкладка «Организация»).")
return
self.log(f"🌐 Запрос доходов из DaData (ИНН {inn_val})…")
def _run():
try:
from .verify import get_company_finance_dadata
res, err = get_company_finance_dadata(inn_val, settings['dadata_token'])
self.root.after(0, lambda: self._load_incomes_done(res, err))
except Exception as e:
self.root.after(0, lambda: self._load_incomes_done(None, str(e)))
threading.Thread(target=_run, daemon=True).start()
def _load_incomes_done(self, res, err):
if err:
messagebox.showerror("DaData", f"Не удалось получить доходы:\n{err}")
self.log(f"❌ Доходы DaData: {err}")
return
finance = res.get('finance') or {}
history = res.get('history') or []
# Доход за последний год из finance (income/revenue)
last_income = finance.get('income') or finance.get('revenue') or None
last_year = finance.get('year') or None
# history: список {year, income, revenue, ...} — сортируем по году
rows = []
for h in history:
y = h.get('year')
inc = h.get('income') or h.get('revenue') or None
if y and inc:
rows.append((int(y), float(inc)))
if last_year and last_income:
rows.append((int(last_year), float(last_income)))
rows = sorted(set(rows), key=lambda x: x[0])
if not rows:
messagebox.showinfo(
"DaData",
f"Финансовые показатели для ИНН {res.get('inn')} в ответе DaData отсутствуют "
f"(нужен тариф DaData с финансовой отчётностью).\n"
f"Заполните доходы за 5 лет вручную.")
return
# Последние 5 лет: income_year_5 (самый свежий) ... income_year_1 (самый старый)
last5 = rows[-5:]
mapping = []
total = 0.0
for i, (y, inc) in enumerate(last5):
# y — календарный год; income_year_1 — самый старый из пяти
mapping.append((f'income_year_{i+1}', y, inc))
total += inc
# Основное поле формы — «Доход за 5 лет (суммарно)»
entry = self._company_entries.get('income_5y')
if entry:
entry.delete(0, tk.END)
entry.insert(0, f"{total:,.0f}".replace(',', ' '))
for key, y, inc in mapping:
entry = self._company_entries.get(key)
if entry:
entry.delete(0, tk.END)
entry.insert(0, f"{inc:,.0f}".replace(',', ' '))
self._collect_company()
years = ', '.join(f"{y}: {inc:,.0f} руб." for _, y, inc in mapping)
self.log(f"🌐 Доходы из DaData загружены: {years}")
messagebox.showinfo("DaData", f"Доходы загружены из DaData (сумма за 5 лет: {total:,.0f} руб.):\n{years}")
def _select_main_okved(self):
"""Выбор основного ОКВЭД из словаря (всегда один)."""
items = self._load_okved_dict()
if not items:
return
current = self._company_entries.get('okved').get().strip() if 'okved' in self._company_entries else ''
dialog = tk.Toplevel(self.root)
dialog.title("Основной ОКВЭД")
dialog.geometry("500x500")
dialog.transient(self.root)
dialog.grab_set()
frame = ttk.Frame(dialog, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text="Выберите основной ОКВЭД (один):",
font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=(0, 5))
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)
# Радио-кнопки: основной всегда один (★ — подходит под 187-ФЗ)
var = tk.StringVar(value=current)
for item in items:
label = ('★ ' + item) if self._is_okved_187(item) else item
ttk.Radiobutton(check_frame, text=label, variable=var,
value=item).pack(anchor=tk.W, pady=1)
ttk.Label(check_frame, text="★ — код подходит под 187-ФЗ (из Распоряжения ПРФ № 360-р)",
foreground='#1a6e1a').pack(anchor=tk.W, pady=(4, 0))
btn_frame = ttk.Frame(dialog, padding=10)
btn_frame.pack(fill=tk.X)
def _apply():
val = var.get()
if val:
entry = self._company_entries.get('okved')
if entry:
entry.delete(0, tk.END)
entry.insert(0, val)
dialog.destroy()
ttk.Button(btn_frame, text="✅ Применить", command=_apply).pack(side=tk.RIGHT, padx=2)
ttk.Button(btn_frame, text="Отмена", command=dialog.destroy).pack(side=tk.RIGHT, padx=2)
dialog.wait_window()
def _select_extra_okved(self):
"""Выбор дополнительных ОКВЭД из словаря (можно несколько)."""
items = self._load_okved_dict()
if not items:
return
display_map = {item: ('★ ' + item) if self._is_okved_187(item) else item
for item in items}
self._select_from_dialog("Выбор дополнительных ОКВЭД", items, self.okved_extra_listbox,
display_map=display_map)
def _add_okved_manual(self):
"""Ручное добавление дополнительного ОКВЭД."""
item = simpledialog.askstring("Добавить ОКВЭД", "Введите код ОКВЭД:")
if item:
self.okved_extra_listbox.insert(tk.END, item.strip())
def _delete_okved_extra(self):
sel = self.okved_extra_listbox.curselection()
if sel:
self.okved_extra_listbox.delete(sel[0])
# ==================== ШАБЛОНЫ ====================
def select_templates(self):
folder = filedialog.askdirectory(title="Папка шаблонов")
if folder:
self.templates_var.set(folder)
self._on_templates_changed()
def _select_save_dir(self):
folder = filedialog.askdirectory(title="Папка для результатов")
if folder:
self.save_var.set(folder)
def _on_templates_changed(self, *_):
"""Обновить список шаблонов при смене папки."""
folder = self.templates_var.get()
self.template_listbox.delete(0, tk.END)
self._template_files = []
if folder and os.path.isdir(folder):
for f in sorted(os.listdir(folder)):
if f.endswith('.docx') and not f.endswith('_result.docx'):
self.template_listbox.insert(tk.END, f)
self._template_files.append(f)
# Выделяем все по умолчанию
self.template_listbox.selection_set(0, tk.END)
self._selected_templates = set(self._template_files)
self.log(f"Найдено шаблонов: {len(self._template_files)}")
def _on_template_select(self, event=None):
sel = self.template_listbox.curselection()
self._selected_templates = {self._template_files[i] for i in sel}
def _select_templates_all(self, select: bool):
if select:
self.template_listbox.selection_set(0, tk.END)
else:
self.template_listbox.selection_clear(0, tk.END)
self._on_template_select()
# ==================== ГЕНЕРАЦИЯ ====================
def generate_all(self):
"""Генерация всех выбранных документов."""
templates_dir = self.templates_var.get()
output_dir = self.save_var.get()
if not templates_dir or not os.path.isdir(templates_dir):
messagebox.showerror("Ошибка", "Выберите папку с шаблонами")
return
if not output_dir:
output_dir = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"Сгенерированные_документы"
)
self.save_var.set(output_dir)
selected = self._selected_templates
if not selected:
messagebox.showwarning("Предупреждение", "Не выбран ни один шаблон")
return
# Собираем данные
self._collect_company()
self._collect_commission()
self._collect_is()
self._collect_employees()
# Проверка
if not self._validate_before_generate():
return
os.makedirs(output_dir, exist_ok=True)
# Окно прогресса
progress_win = tk.Toplevel(self.root)
progress_win.title("Генерация документов")
progress_win.geometry("350x100")
progress_win.transient(self.root)
progress_win.grab_set()
progress_win.resizable(False, False)
ttk.Label(progress_win, text="Генерация документов...",
font=('Arial', 10)).pack(pady=(15, 5))
pb = ttk.Progressbar(progress_win, mode='indeterminate', length=300)
pb.pack(pady=5)
pb.start()
def _close_progress():
try:
pb.stop()
progress_win.grab_release()
progress_win.destroy()
except Exception:
pass
# Запускаем в отдельном потоке
def _run():
try:
replacements = build_replacements(
company=self.model,
is_list=self.model.information_systems,
doc_number=self.doc_number_var.get(),
doc_date=self.doc_date_var.get(),
direction='187fz',
)
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="Выберите опросный лист (187-ФЗ)",
filetypes=[("Excel", "*.xlsx *.xls")]
)
if not path:
return
self.log(f"Импорт: {path}")
# Импорт в отдельном потоке
def _run():
try:
from .importer import import_187fz
data = import_187fz(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()
# Ответы по физической безопасности (для автогенерации орг. мер)
if data.get('physicalSecurity'):
self.model.physical_security = dict(data['physicalSecurity'])
# Должностные лица (маппинг)
officials_map = {
'chief_position': 'chiefPosition',
'chief_fio': 'chiefFio',
'responsible_position': 'responsiblePosition',
'responsible_fio': 'responsibleFio',
'admin_position': 'administratorPosition',
'admin_fio': 'administratorFio',
}
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'])
# Финансовые показатели (прибыль, налог)
for entry_key, data_keys in [('profit', 'profit'), ('profit_tax', 'profitTax')]:
entry = self._company_entries.get(entry_key)
if entry and data.get(data_keys):
entry.delete(0, tk.END)
entry.insert(0, data[data_keys])
# Показатель 8: доходы за 5 лет + суммарный ущерб от нарушения критических процессов
income_map = [
('income_5y', ('income5Y', 'income_5y', 'income5y')),
('income_year_1', ('incomeYear1', 'income_year_1')),
('income_year_2', ('incomeYear2', 'income_year_2')),
('income_year_3', ('incomeYear3', 'income_year_3')),
('income_year_4', ('incomeYear4', 'income_year_4')),
('income_year_5', ('incomeYear5', 'income_year_5')),
('income_loss', ('incomeLoss', 'income_loss')),
]
for entry_key, data_keys in income_map:
entry = self._company_entries.get(entry_key)
if not entry:
continue
val = None
for dk in data_keys:
if data.get(dk):
val = data[dk]
break
if val:
entry.delete(0, tk.END)
entry.insert(0, val)
# Наименование структурного подразделения по безопасности
if data.get('ispdnDepartment'):
self.model.security_department = data['ispdnDepartment']
# Доменное имя и внешний сетевой адрес
if data.get('domainName'):
entry = self._company_entries.get('domain_name')
if entry:
entry.delete(0, tk.END)
entry.insert(0, data['domainName'])
# Доменное имя и внешний сетевой адрес
if data.get('domainName'):
entry = self._company_entries.get('domain_name')
if entry:
entry.delete(0, tk.END)
entry.insert(0, data['domainName'])
# ОКВЭД (основной и дополнительные)
if data.get('okved'):
entry = self._company_entries.get('okved')
if entry:
from .verify import resolve_okved
from .api_settings import load_api_settings as _las
_tok = (_las().get('dadata_token') or '')
entry.delete(0, tk.END)
entry.insert(0, resolve_okved(data['okved'], _tok))
if data.get('okvedExtra'):
if hasattr(self, 'okved_extra_listbox'):
from .verify import resolve_okved, split_okved_extra
from .api_settings import load_api_settings as _las2
_tok2 = (_las2().get('dadata_token') or '')
self.okved_extra_listbox.delete(0, tk.END)
# Доп. ОКВЭД могут быть склеены с переносами внутри названий
# («25.93 — Производство изделий из проволоки\nцепей и пружин 32.12.1 — …») —
# нарезаем по кодам ОКВЭД, затем нормализуем каждый в «код название».
for p in split_okved_extra(data['okvedExtra']):
self.okved_extra_listbox.insert(tk.END, resolve_okved(p, _tok2))
# Объекты КИИ
if data.get('informationSystems'):
self.model.information_systems.clear()
for is_data in data['informationSystems']:
# Фильтр по сфере: оставляем только показатели, применимые к сфере объекта
# (организация заполняет в опроснике только свои; лишнее отбрасываем).
_crit_in = dict(is_data.get('criterion_input', {}) or {})
if _crit_in:
import re as _re_sphere
_appl = None
try:
import json as _json_sphere
_ap_path = os.path.join(os.path.dirname(__file__),
'dictionaries', 'criteria_applicability.json')
with open(_ap_path, encoding='utf-8') as _f_sphere:
_appl_all = _json_sphere.load(_f_sphere)
_appl = _appl_all.get(is_data.get('sphere', '') or '')
except Exception:
_appl = None
if _appl:
_filtered = {}
for _n, _v in _crit_in.items():
_m = _re_sphere.match(r'(\d+)', str(_n))
_g = _m.group(1) if _m else str(_n)
if _g in _appl:
_filtered[_n] = _v
_crit_in = _filtered
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),
category=is_data.get('category', ''),
defense_tools_list=is_data.get('defense_tools', []),
users_list=is_data.get('usersList', []),
purpose=is_data.get('purpose', ''),
structure=is_data.get('structure', ''),
software=is_data.get('software', ''),
sphere=is_data.get('sphere', ''),
object_type=is_data.get('object_type', ''),
architecture=is_data.get('architecture', ''),
typical_object=is_data.get('typical_object', ''),
subject_name=is_data.get('subject_name', ''),
subject_address=is_data.get('subject_address', ''),
subject_element=is_data.get('subject_element', ''),
subject_inn=is_data.get('subject_inn', ''),
subject_kpp=is_data.get('subject_kpp', ''),
net_category=is_data.get('net_category', ''),
net_operator=is_data.get('net_operator', ''),
net_purpose=is_data.get('net_purpose', ''),
net_method=is_data.get('net_method', ''),
hw_sw=is_data.get('hw_sw', ''),
os_sw=is_data.get('os_sw', ''),
app_sw=is_data.get('app_sw', ''),
balance_value=is_data.get('balanceValue', is_data.get('balance_value', '')),
attacker_category=is_data.get('attacker_category', ''),
attacker_level=is_data.get('attacker_level', ''),
attacker_types=is_data.get('attacker_types', ''),
threats=normalize_threats(is_data.get('threats', '')),
incidents=normalize_threats(is_data.get('incidents', '')),
criterion_input=_crit_in,
criteria_values=is_data.get('criteria_values', ''),
criteria_justification=is_data.get('criteria_justification', ''),
org_measures=is_data.get('org_measures', ''),
tech_measures=is_data.get('tech_measures', ''),
)
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']:
fio = str(emp.get('fio', '')).strip()
pos = str(emp.get('position', '')).strip()
if fio.lower().startswith('формат:') or fio.lower().startswith('пример:'):
continue
if not fio and not pos:
continue
self.model.employees_access.append({
'position': pos,
'fio': fio,
})
self._add_employee_row(pos, fio)
self._update_emp_count()
# ⚡ Автозаполнение безопасности по каждому объекту (логика программы).
# После импорта опросника программа сама заполняет всё, что можно вывести
# логически; остальное оператор добивает вручную.
try:
from .threat_rules import auto_fill_security, load_threats
_threats_full = load_threats()
_filled_any = 0
for _isys in self.model.information_systems:
_fill = auto_fill_security(_isys, threats=_threats_full)
if _fill.get('attacker_category'):
_isys.attacker_category = _fill['attacker_category']
if _fill.get('attacker_level'):
_isys.attacker_level = _fill['attacker_level']
if _fill.get('attacker_types'):
_isys.attacker_types = _fill['attacker_types']
if _fill.get('threats'):
_isys.threats = _fill['threats']
if _fill.get('incidents'):
_isys.incidents = _fill['incidents']
if _fill.get('org_measures'):
_isys.org_measures = _fill['org_measures']
if _fill.get('tech_measures'):
_isys.tech_measures = _fill['tech_measures']
if _fill:
_filled_any += 1
if _filled_any:
self.log(f"⚡ Автозаполнение безопасности: обработано объектов: {_filled_any}")
# Обновляем форму выбранного объекта, если он есть
self._on_is_select()
except Exception as _e:
self.log(f"⚠ Автозаполнение безопасности: {_e}")
self.log("✅ Импорт применён")
self.root.after(0, _apply)
# ==================== ФАЙЛОВЫЕ ОПЕРАЦИИ ====================
def new_data(self):
if self.model.full_name:
if not messagebox.askyesno("Новое", "Текущие данные будут потеряны. Продолжить?"):
return
self._init_company()
self.current_file = None
self._company_to_gui()
self.log("Новые данные")
def open_data(self):
filename = filedialog.askopenfilename(filetypes=[("JSON", "*.json")])
if filename:
try:
with open(filename, 'r', encoding='utf-8') as f:
data = json.load(f)
self.model = Company.from_dict(data)
self.current_file = filename
self._company_to_gui()
self.log(f"Загружено: {filename}")
except Exception as e:
messagebox.showerror("Ошибка", str(e))
def save_data(self):
if self.current_file:
self._save_to(self.current_file)
else:
self.save_as_data()
def save_as_data(self):
filename = filedialog.asksaveasfilename(
defaultextension=".json", filetypes=[("JSON", "*.json")]
)
if filename:
self._save_to(filename)
def _save_to(self, filename):
self._collect_company()
self._collect_commission()
self._collect_is()
self._collect_employees()
try:
with open(filename, 'w', encoding='utf-8') as f:
json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2)
self.current_file = filename
self.log(f"Сохранено: {filename}")
except Exception as e:
messagebox.showerror("Ошибка", str(e))
# ==================== АВТОСОХРАНЕНИЕ ====================
def _start_autosave_timer(self):
self._autosave()
self._autosave_timer_id = self.root.after(120000, self._start_autosave_timer)
def _stop_autosave_timer(self):
if hasattr(self, '_autosave_timer_id') and self._autosave_timer_id:
try:
self.root.after_cancel(self._autosave_timer_id)
except:
pass
def _autosave(self):
self._collect_company()
self._collect_commission()
self._collect_is()
self._collect_employees()
try:
os.makedirs(os.path.dirname(self._autosave_path), exist_ok=True)
with open(self._autosave_path, 'w', encoding='utf-8') as f:
json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2)
except Exception:
pass
def _load_autosave(self):
try:
if not os.path.isfile(self._autosave_path):
return
mtime = os.path.getmtime(self._autosave_path)
if datetime.now().timestamp() - mtime > 86400:
return
with open(self._autosave_path, 'r', encoding='utf-8') as f:
data = json.load(f)
self.model = Company.from_dict(data)
self._company_to_gui()
self.log("📂 Данные восстановлены из автосохранения")
except Exception:
pass
def _on_close(self):
self._stop_autosave_timer()
self._autosave()
self.root.destroy()
# ==================== КОНТЕКСТНОЕ МЕНЮ ====================
def _enable_context_menu(self):
def _show_menu(event):
widget = event.widget
if not isinstance(widget, (tk.Entry, ttk.Entry, tk.Text, tk.Listbox)):
return
menu = tk.Menu(self.root, tearoff=0)
def _copy():
try:
text = ""
if isinstance(widget, (tk.Entry, ttk.Entry)):
text = widget.selection_get() if widget.selection_present() else ""
elif isinstance(widget, tk.Text):
text = widget.selection_get() if widget.tag_ranges(tk.SEL) else ""
if text:
self.root.clipboard_clear()
self.root.clipboard_append(text)
except:
pass
def _paste():
try:
text = self.root.clipboard_get()
if not text: return
if isinstance(widget, (tk.Entry, ttk.Entry)):
try: widget.delete(tk.SEL_FIRST, tk.SEL_LAST)
except: pass
widget.insert(tk.INSERT, text)
elif isinstance(widget, tk.Text):
try:
if widget.tag_ranges(tk.SEL):
widget.delete(tk.SEL_FIRST, tk.SEL_LAST)
except: pass
widget.insert(tk.INSERT, text)
except:
pass
menu.add_command(label="Копировать", command=_copy)
menu.add_command(label="Вставить", command=_paste)
menu.add_separator()
menu.add_command(label="Выделить всё", command=lambda: widget.select_range(0, tk.END) if hasattr(widget, 'select_range') else None)
menu.tk_popup(event.x_root, event.y_root)
self.root.bind_all('<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("О программе",
"ДоКоГеНеРаТоР — 187-ФЗ\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(), "Опросный_лист_187_ФЗ.xlsx", "Сохранить пустой опросник")
def main():
"""Точка входа в приложение."""
root = tk.Tk()
app = DokoGenApp(root)
root.mainloop()