Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72bbb117b9 |
@@ -6,8 +6,5 @@ from .declension import inflect_name, decline, company_name_decline, get_short_f
|
||||
from .variables import build_replacements, VARIABLE_DEFS
|
||||
from .generator import process_template
|
||||
|
||||
# Импорт из Excel
|
||||
from . import importer
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__app_name__ = "ДоКоГеНеРаТоР"
|
||||
@@ -1,14 +0,0 @@
|
||||
[
|
||||
"Антивирусное ПО",
|
||||
"СКЗИ (криптография)",
|
||||
"Межсетевой экран",
|
||||
"СЗИ от НСД",
|
||||
"СОВ (обнаружение вторжений)",
|
||||
"Доверенная загрузка",
|
||||
"SIEM-система",
|
||||
"DLP-система",
|
||||
"VPN",
|
||||
"Система парольной защиты",
|
||||
"Антиспам",
|
||||
"Средства резервного копирования"
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
[
|
||||
"Сбор персональных данных",
|
||||
"Запись персональных данных",
|
||||
"Систематизация персональных данных",
|
||||
"Накопление персональных данных",
|
||||
"Хранение персональных данных",
|
||||
"Уточнение (обновление, изменение) персональных данных",
|
||||
"Извлечение персональных данных",
|
||||
"Использование персональных данных",
|
||||
"Передача (распространение, предоставление, доступ) персональных данных",
|
||||
"Обезличивание персональных данных",
|
||||
"Блокирование персональных данных",
|
||||
"Удаление персональных данных",
|
||||
"Уничтожение персональных данных",
|
||||
"Трансграничная передача персональных данных",
|
||||
"Автоматизированная обработка персональных данных",
|
||||
"Неавтоматизированная обработка персональных данных",
|
||||
"Смешанная обработка персональных данных",
|
||||
"Формирование и ведение баз данных",
|
||||
"Передача данных третьим лицам",
|
||||
"Внутренняя передача данных между подразделениями"
|
||||
]
|
||||
@@ -1,8 +0,0 @@
|
||||
[
|
||||
"работники оператора",
|
||||
"клиенты",
|
||||
"контрагенты",
|
||||
"родственники работников",
|
||||
"соискатели",
|
||||
"иные лица"
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
[
|
||||
"Фамилия, имя, отчество",
|
||||
"Дата рождения",
|
||||
"Место рождения",
|
||||
"Паспортные данные",
|
||||
"Адрес регистрации",
|
||||
"Адрес проживания",
|
||||
"ИНН",
|
||||
"СНИЛС",
|
||||
"Номер телефона",
|
||||
"Адрес электронной почты",
|
||||
"Образование",
|
||||
"Профессия",
|
||||
"Сведения о доходах",
|
||||
"Семейное положение",
|
||||
"Состав семьи",
|
||||
"Состояние здоровья",
|
||||
"Национальность",
|
||||
"Гражданство",
|
||||
"Сведения о судимости",
|
||||
"Фотография",
|
||||
"Биометрические данные",
|
||||
"Рабочий e-mail",
|
||||
"Должность",
|
||||
"Табельный номер",
|
||||
"Сведения о воинском учете",
|
||||
"Сведения о командировках"
|
||||
]
|
||||
@@ -1,430 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DokoGen — Генератор документов из шаблонов DOCX (чистая версия)"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import io
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Dict, List, Optional, Callable, Any
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
from lxml import etree
|
||||
DOCX_AVAILABLE = True
|
||||
except ImportError:
|
||||
Document = None
|
||||
DOCX_AVAILABLE = False
|
||||
|
||||
# ============================================================
|
||||
# 1. ПЕРЕМЕННЫЕ ШАБЛОНОВ И РЕГЕКСЫ
|
||||
# ============================================================
|
||||
|
||||
# Паттерны для поиска переменных и маркеров циклов
|
||||
VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}')
|
||||
OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>')
|
||||
UNMATCHED_PATTERN = re.compile(r'\{\{[^}]+\}\}|<[A-Za-z0-9_]+>')
|
||||
|
||||
LOOP_START = re.compile(r'(?:<!--|<!--)\s*loop:(\w+)\s*(?:-->|-->)')
|
||||
LOOP_END = re.compile(r'(?:<!--|<!--)\s*loop_end\s*(?:-->|-->)')
|
||||
|
||||
# Маппинг старых переменных <...> → {{...}}
|
||||
OLD_VAR_MAP = {
|
||||
'<CompanyName1>': '{{company_full_name}}',
|
||||
'<CompanyName2>': '{{company_short_name}}',
|
||||
'<CompanyName1Gent>': '{{company_full_name_genitive}}',
|
||||
'<CompanyName2Gent>': '{{company_short_name_genitive}}',
|
||||
'<CompanyName1Datv>': '{{company_full_name_dative}}',
|
||||
'<CompanyName2Datv>': '{{company_short_name_dative}}',
|
||||
'<CompanyName1Accs>': '{{company_full_name_accs}}',
|
||||
'<CompanyName2Accs>': '{{company_short_name_accs}}',
|
||||
'<CompanyName1Ablt>': '{{company_full_name_ablt}}',
|
||||
'<CompanyName2Ablt>': '{{company_short_name_ablt}}',
|
||||
'<CompanyName1Loct>': '{{company_full_name_loct}}',
|
||||
'<CompanyName2Loct>': '{{company_short_name_loct}}',
|
||||
'<Address>': '{{company_address}}',
|
||||
'<INN>': '{{company_inn}}',
|
||||
'<OGRN>': '{{company_ogrn}}',
|
||||
'<KPP>': '{{company_kpp}}',
|
||||
'<ChiefFio>': '{{chief_fio_initials}}',
|
||||
'<ChiefFioShort>': '{{chief_fio_short}}',
|
||||
'<ChiefPosition>': '{{chief_position}}',
|
||||
'<RspFio>': '{{responsible_fio_initials}}',
|
||||
'<RspFioShort>': '{{responsible_fio_short}}',
|
||||
'<RspPosition>': '{{responsible_position}}',
|
||||
'<CommissionList>': '{{commission_list}}',
|
||||
'<DocumentNumber>': '{{document_number}}',
|
||||
'<DocumentDate>': '{{document_date}}',
|
||||
'<Date>': '{{date}}',
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2. ОБРАБОТКА ЦИКЛОВ НА УРОВНЕ ZIP/XML
|
||||
# ============================================================
|
||||
|
||||
def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
|
||||
"""Обрабатывает циклы напрямую в ZIP/DOCX на уровне XML.
|
||||
Работает до загрузки python-docx — гарантирует корректную вложенность.
|
||||
|
||||
Args:
|
||||
docx_path: путь к шаблону .docx
|
||||
loops: словарь {имя_цикла: [{field: value}, ...]}
|
||||
|
||||
Returns:
|
||||
путь к обработанному .docx (временный файл)
|
||||
"""
|
||||
if not loops:
|
||||
return docx_path
|
||||
|
||||
# Временный файл
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.docx')
|
||||
tmp.close()
|
||||
|
||||
max_passes = 30
|
||||
for _ in range(max_passes):
|
||||
# Читаем ВСЕ файлы из исходного ZIP
|
||||
all_files = {}
|
||||
try:
|
||||
with zipfile.ZipFile(docx_path if _ == 0 else tmp.name, 'r') as z:
|
||||
for name in z.namelist():
|
||||
all_files[name] = z.read(name)
|
||||
doc_xml = all_files.get('word/document.xml', b'').decode('utf-8')
|
||||
except Exception:
|
||||
break
|
||||
|
||||
if not doc_xml:
|
||||
break
|
||||
|
||||
# Находим ВСЕ маркеры циклов
|
||||
start_markers = list(LOOP_START.finditer(doc_xml))
|
||||
end_markers = list(LOOP_END.finditer(doc_xml))
|
||||
|
||||
if not start_markers:
|
||||
break # нет больше циклов
|
||||
|
||||
# Внешний цикл = первый start (самый ранний в документе)
|
||||
outer = start_markers[0]
|
||||
outer_key = outer.group(1)
|
||||
|
||||
# Соответствующий end = первый end после outer (или последний, если вложенные)
|
||||
# Считаем глубину: каждый start +1, каждый end -1
|
||||
depth = 1
|
||||
outer_end = None
|
||||
all_markers = sorted(
|
||||
[('start', m) for m in start_markers] +
|
||||
[('end', m) for m in end_markers],
|
||||
key=lambda x: x[1].start()
|
||||
)
|
||||
found_start = False
|
||||
for kind, m in all_markers:
|
||||
if m.start() <= outer.start():
|
||||
continue
|
||||
if kind == 'start':
|
||||
depth += 1
|
||||
elif kind == 'end':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
outer_end = m
|
||||
break
|
||||
|
||||
if outer_end is None:
|
||||
break
|
||||
|
||||
items = loops.get(outer_key, [])
|
||||
if not items:
|
||||
# Удаляем блок цикла полностью
|
||||
doc_xml = doc_xml[:outer.start()] + doc_xml[outer_end.end():]
|
||||
all_files['word/document.xml'] = doc_xml.encode('utf-8')
|
||||
with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
|
||||
for name, data in all_files.items():
|
||||
z.writestr(name, data)
|
||||
continue
|
||||
|
||||
# Извлекаем шаблон (всё между start и end маркерами)
|
||||
template = doc_xml[outer.end():outer_end.start()]
|
||||
|
||||
# НЕ удаляем loop_end из шаблона — они принадлежат вложенным циклам!
|
||||
template_clean = template
|
||||
|
||||
# Размножаем шаблон для каждого элемента данных
|
||||
expanded_parts = []
|
||||
for idx, item in enumerate(items, 1):
|
||||
clone = template_clean
|
||||
for field, value in item.items():
|
||||
clone = clone.replace('{{item.%s}}' % field, str(value) if value else '—')
|
||||
clone = clone.replace('{{item.number}}', str(idx))
|
||||
clone = clone.replace('{{number}}', str(idx))
|
||||
expanded_parts.append(clone)
|
||||
|
||||
# Собираем новый XML
|
||||
new_xml = doc_xml[:outer.start()] + ''.join(expanded_parts) + doc_xml[outer_end.end():]
|
||||
|
||||
# Сохраняем ВСЕ файлы ZIP, обновляя только document.xml
|
||||
all_files['word/document.xml'] = new_xml.encode('utf-8')
|
||||
with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
|
||||
for name, data in all_files.items():
|
||||
z.writestr(name, data)
|
||||
|
||||
return tmp.name
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 3. ЗАМЕНА ПЕРЕМЕННЫХ В ЭЛЕМЕНТАХ DOCX
|
||||
# ============================================================
|
||||
|
||||
def _replace_text_in_para(para, replacements: Dict[str, str]) -> bool:
|
||||
"""Заменяет {{var}} в параграфе. Возвращает True, если были замены."""
|
||||
full = para.text
|
||||
matches = VAR_PATTERN.findall(full)
|
||||
if not matches:
|
||||
return False
|
||||
|
||||
new_text = full
|
||||
for var in matches:
|
||||
val = replacements.get(var)
|
||||
if val is not None:
|
||||
new_text = new_text.replace(var, str(val))
|
||||
|
||||
if new_text == full:
|
||||
return False
|
||||
|
||||
# Сохраняем форматирование первого run
|
||||
if para.runs:
|
||||
first = para.runs[0]
|
||||
para.clear()
|
||||
run = para.add_run(new_text)
|
||||
_copy_run_style(first, run)
|
||||
else:
|
||||
para.clear()
|
||||
para.add_run(new_text)
|
||||
return True
|
||||
|
||||
|
||||
def _copy_run_style(source, target):
|
||||
"""Копирует форматирование run."""
|
||||
try:
|
||||
target.bold = source.bold
|
||||
target.italic = source.italic
|
||||
target.underline = source.underline
|
||||
if source.font:
|
||||
target.font.size = source.font.size
|
||||
target.font.name = source.font.name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _replace_in_paragraphs(paragraphs, replacements):
|
||||
for para in paragraphs:
|
||||
_replace_text_in_para(para, replacements)
|
||||
|
||||
|
||||
def _replace_in_tables(tables, replacements):
|
||||
for table in tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
_replace_in_paragraphs(cell.paragraphs, replacements)
|
||||
|
||||
|
||||
def _replace_in_headers_footers(doc, replacements):
|
||||
for section in doc.sections:
|
||||
for hf in [section.header, section.footer,
|
||||
section.first_page_header, section.first_page_footer]:
|
||||
if hf:
|
||||
_replace_in_paragraphs(hf.paragraphs, replacements)
|
||||
|
||||
|
||||
def _strip_prooferr(doc):
|
||||
"""Удаляет proofErr из docx (красные волнистые линии)."""
|
||||
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
for elem in doc.element.iter():
|
||||
for child in list(elem):
|
||||
if child.tag == f'{{{ns}}}proofErr':
|
||||
elem.remove(child)
|
||||
|
||||
|
||||
def _highlight_unmatched(doc):
|
||||
"""Подсвечивает незаменённые переменные красным."""
|
||||
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
|
||||
def _highlight_run(run):
|
||||
rpr = run._element.find(f'{{{ns}}}rPr')
|
||||
if rpr is None:
|
||||
rpr = OxmlElement('w:rPr')
|
||||
run._element.insert(0, rpr)
|
||||
color = OxmlElement('w:color')
|
||||
color.set(f'{{{ns}}}val', 'FF0000')
|
||||
rpr.append(color)
|
||||
|
||||
for para in doc.paragraphs:
|
||||
for run in para.runs:
|
||||
if UNMATCHED_PATTERN.search(run.text or ''):
|
||||
_highlight_run(run)
|
||||
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
for run in para.runs:
|
||||
if UNMATCHED_PATTERN.search(run.text or ''):
|
||||
_highlight_run(run)
|
||||
|
||||
for section in doc.sections:
|
||||
for hf in [section.header, section.footer]:
|
||||
if hf:
|
||||
for para in hf.paragraphs:
|
||||
for run in para.runs:
|
||||
if UNMATCHED_PATTERN.search(run.text or ''):
|
||||
_highlight_run(run)
|
||||
|
||||
|
||||
def _log_unmatched(doc, replacements=None, log_func=None):
|
||||
"""Логирует незаменённые переменные."""
|
||||
if not log_func:
|
||||
return
|
||||
unmatched = []
|
||||
seen = set()
|
||||
for para in doc.paragraphs:
|
||||
for m in UNMATCHED_PATTERN.finditer(para.text):
|
||||
var = m.group(0)
|
||||
if var not in seen:
|
||||
seen.add(var)
|
||||
unmatched.append(var)
|
||||
if unmatched:
|
||||
log_func(f"⚠️ Незаменённые переменные ({len(unmatched)}): "
|
||||
f"{', '.join(unmatched[:20])}"
|
||||
f"{' ...' if len(unmatched) > 20 else ''}")
|
||||
else:
|
||||
log_func("✅ Все переменные успешно заменены")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 4. МИГРАЦИЯ СТАРЫХ ПЕРЕМЕННЫХ
|
||||
# ============================================================
|
||||
|
||||
def _migrate_old_vars(doc):
|
||||
"""Заменяет <OldVar> на {{new_var}}."""
|
||||
def _migrate_text(text):
|
||||
return OLD_VAR_PATTERN.sub(lambda m: OLD_VAR_MAP.get(m.group(0), m.group(0)), text)
|
||||
|
||||
for para in doc.paragraphs:
|
||||
new_text = _migrate_text(para.text)
|
||||
if new_text != para.text:
|
||||
if para.runs:
|
||||
first = para.runs[0]
|
||||
para.clear()
|
||||
run = para.add_run(new_text)
|
||||
_copy_run_style(first, run)
|
||||
else:
|
||||
para.clear()
|
||||
para.add_run(new_text)
|
||||
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
new_text = _migrate_text(para.text)
|
||||
if new_text != para.text:
|
||||
if para.runs:
|
||||
first = para.runs[0]
|
||||
para.clear()
|
||||
run = para.add_run(new_text)
|
||||
_copy_run_style(first, run)
|
||||
else:
|
||||
para.clear()
|
||||
para.add_run(new_text)
|
||||
|
||||
for section in doc.sections:
|
||||
for hf in [section.header, section.footer,
|
||||
section.first_page_header, section.first_page_footer]:
|
||||
if hf:
|
||||
for para in hf.paragraphs:
|
||||
new_text = _migrate_text(para.text)
|
||||
if new_text != para.text:
|
||||
if para.runs:
|
||||
first = para.runs[0]
|
||||
para.clear()
|
||||
run = para.add_run(new_text)
|
||||
_copy_run_style(first, run)
|
||||
else:
|
||||
para.clear()
|
||||
para.add_run(new_text)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 5. ОСНОВНАЯ ФУНКЦИЯ ГЕНЕРАЦИИ
|
||||
# ============================================================
|
||||
|
||||
def process_template(
|
||||
template_path: str,
|
||||
replacements: Dict[str, str],
|
||||
output_path: Optional[str] = None,
|
||||
log_func: Optional[Callable] = None,
|
||||
) -> str:
|
||||
"""Обрабатывает шаблон DOCX: подстановка переменных, циклы, сохранение.
|
||||
|
||||
Args:
|
||||
template_path: путь к файлу шаблона .docx
|
||||
replacements: словарь {переменная: значение}
|
||||
output_path: путь для сохранения результата
|
||||
log_func: функция логирования (str → None)
|
||||
|
||||
Returns:
|
||||
путь к сгенерированному файлу
|
||||
"""
|
||||
if not DOCX_AVAILABLE:
|
||||
raise ImportError("python-docx не установлен")
|
||||
|
||||
if not os.path.exists(template_path):
|
||||
raise FileNotFoundError(f"Шаблон не найден: {template_path}")
|
||||
|
||||
if output_path is None:
|
||||
base, ext = os.path.splitext(template_path)
|
||||
output_path = f"{base}_result{ext}"
|
||||
|
||||
# ШАГ 1: Обрабатываем циклы на уровне ZIP/XML (до загрузки python-docx)
|
||||
loops = replacements.pop('_loops', {})
|
||||
working_path = template_path
|
||||
if loops:
|
||||
working_path = expand_loops_in_zip(template_path, loops)
|
||||
replacements['_loops'] = loops # возвращаем на всякий случай
|
||||
|
||||
# ШАГ 2: Загружаем через python-docx
|
||||
doc = Document(working_path)
|
||||
|
||||
# ШАГ 3: Миграция старых переменных <...> → {{...}}
|
||||
_migrate_old_vars(doc)
|
||||
|
||||
# ШАГ 4: Убираем proofErr
|
||||
_strip_prooferr(doc)
|
||||
|
||||
# ШАГ 5: Замена переменных (3 прохода)
|
||||
for _ in range(3):
|
||||
_replace_in_paragraphs(doc.paragraphs, replacements)
|
||||
_replace_in_tables(doc.tables, replacements)
|
||||
_replace_in_headers_footers(doc, replacements)
|
||||
|
||||
# ШАГ 6: Логирование незаменённых
|
||||
_log_unmatched(doc, replacements, log_func)
|
||||
|
||||
# ШАГ 7: Подсветка незаполненных
|
||||
_highlight_unmatched(doc)
|
||||
|
||||
# ШАГ 8: Сохранение
|
||||
doc.save(output_path)
|
||||
|
||||
# ШАГ 9: Если использовали временный файл — удаляем
|
||||
if working_path != template_path:
|
||||
try:
|
||||
os.unlink(working_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if log_func:
|
||||
log_func(f"✅ {os.path.basename(output_path)}")
|
||||
return output_path
|
||||
@@ -1,430 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DokoGen — Модуль импорта из Excel (опросный лист 152-ФЗ)"""
|
||||
|
||||
import re
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable, Optional
|
||||
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
openpyxl = None
|
||||
|
||||
|
||||
def clean_value(val):
|
||||
"""Очистка и нормализация значения ячейки Excel."""
|
||||
if val is None:
|
||||
return ''
|
||||
if isinstance(val, (int, float)):
|
||||
# Попытка распознать дату (серийный номер Excel)
|
||||
if 10000 < val < 80000:
|
||||
serial = int(val)
|
||||
if serial > 60:
|
||||
serial -= 1
|
||||
dt = datetime(1899, 12, 30) + timedelta(days=serial)
|
||||
return dt.strftime('%d.%m.%Y')
|
||||
if isinstance(val, float) and val == int(val):
|
||||
return str(int(val))
|
||||
return str(val)
|
||||
text = str(val).strip()
|
||||
text = text.replace('\n', ' ').replace('\r', ' ')
|
||||
text = ' '.join(text.split())
|
||||
# Приведение регистра: если текст содержит только заглавные буквы РУ
|
||||
if len(text) > 4 and any('А' <= c <= 'Я' or c == 'Ё' for c in text):
|
||||
has_lower = any('а' <= c <= 'я' or c == 'ё' for c in text)
|
||||
if not has_lower:
|
||||
words = text.split()
|
||||
if len(words) >= 3:
|
||||
text = text.title()
|
||||
else:
|
||||
text = text.capitalize()
|
||||
return text
|
||||
|
||||
|
||||
def import_152fz(filepath: str, log_fn: Optional[Callable] = None) -> dict:
|
||||
"""Импорт данных из опросного листа Excel (152-ФЗ)."""
|
||||
if openpyxl is None:
|
||||
raise ImportError("openpyxl не установлен. Установите: pip install openpyxl")
|
||||
|
||||
if log_fn is None:
|
||||
log_fn = lambda msg: None
|
||||
|
||||
log_fn(f"Загрузка опросного листа: {filepath}")
|
||||
|
||||
data = {
|
||||
'fullName': '', 'shortName': '', 'addressLegal': '', 'addressActual': '',
|
||||
'email': '', 'phone': '', 'inn': '', 'ogrn': '', 'kpp': '',
|
||||
'okved': '', 'chiefPosition': '', 'chiefFio': '',
|
||||
'ispdnFio': '', 'ispdnPosition': '', 'paperDocuments': '', 'paperStorage': '',
|
||||
'ispdnDepartment': '', 'ispdnEmail': '', 'ispdnPhone': '',
|
||||
'informationSystems': [], 'commission': [], 'employeesAccess': [],
|
||||
}
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(filepath, data_only=True, read_only=False)
|
||||
except Exception as e:
|
||||
log_fn(f"❌ Ошибка открытия файла: {e}")
|
||||
return data
|
||||
|
||||
try:
|
||||
_import_152_common(wb, data, log_fn)
|
||||
_import_152_is_list(wb, data, log_fn)
|
||||
_import_152_employees(wb, data, log_fn)
|
||||
except Exception as e:
|
||||
log_fn(f"❌ Ошибка при импорте: {e}")
|
||||
log_fn(traceback.format_exc())
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
# Копирование полей ИСПДн в админа, если админ не заполнен
|
||||
for src, dst in [('ispdnFio', 'administratorFio'), ('ispdnFio', 'adminFio'),
|
||||
('ispdnPosition', 'administratorPosition'), ('ispdnPosition', 'adminPosition'),
|
||||
('ispdnPhone', 'phone'), ('ispdnEmail', 'email')]:
|
||||
if data.get(src) and not data.get(dst):
|
||||
data[dst] = data[src]
|
||||
|
||||
_validate_company_data(data, log_fn)
|
||||
return data
|
||||
|
||||
|
||||
def _import_152_common(wb, data, log_fn):
|
||||
"""Импорт общих сведений из листа 'Общие сведения'."""
|
||||
sheet_name = None
|
||||
for sn in wb.sheetnames:
|
||||
if 'общие' in sn.lower():
|
||||
sheet_name = sn
|
||||
break
|
||||
if not sheet_name:
|
||||
log_fn("⚠ Лист 'Общие сведения' не найден")
|
||||
return
|
||||
|
||||
ws = wb[sheet_name]
|
||||
field_map = [
|
||||
('полное наименование организации', 'fullName'),
|
||||
('сокращенное наименование организации', 'shortName'),
|
||||
('адрес организации \\(юридический\\)', 'addressLegal'),
|
||||
('адрес организации \\(фактический\\)', 'addressActual'),
|
||||
('инн', 'inn'),
|
||||
('дата.*огрн', 'ogrnDate'),
|
||||
('огрн', 'ogrn'),
|
||||
('кпп', 'kpp'),
|
||||
('основной оквэд', 'okved'),
|
||||
('должность руководителя', 'chiefPosition'),
|
||||
('фио руководителя', 'chiefFio'),
|
||||
('должность.*защит.*информ|должность.*администрат', 'ispdnPosition'),
|
||||
('фио.*защит.*информ|фио.*администрат', 'ispdnFio'),
|
||||
('электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист', 'ispdnEmail'),
|
||||
('телефон.*защит|телефон.*ответств|телефон.*специалист', 'ispdnPhone'),
|
||||
('структур.*подраздел', 'ispdnDepartment'),
|
||||
('номер.*договора|номер.*контракта', 'contractNumber'),
|
||||
('дата.*договора|дата.*контракта', 'contractDate'),
|
||||
('названи.*документ', 'paperDocuments'),
|
||||
('место.*хранен', 'paperStorage'),
|
||||
('должность.*ответственн', 'responsiblePosition'),
|
||||
('фио.*ответственн', 'responsibleFio'),
|
||||
('должность.*администрат.*(?:безопас|защит|пд)', 'administratorPosition'),
|
||||
('фио.*администрат.*(?:безопас|защит|пд)', 'administratorFio'),
|
||||
('сайт организации', 'site_name'),
|
||||
('должность ответственного за сайт|должность.*отв.*сайт', 'site_responsible_position'),
|
||||
('фио.*отв.*сайт|фио.*сайт', 'site_responsible_fio'),
|
||||
('наименование организации.*разрабат.*сайт|наименование.*размещала.*сайт|разрабат.*сайт.*организац', 'site_service_provider'),
|
||||
('инн.*разрабат.*сайт|инн.*размещала.*сайт', 'site_service_provider_inn'),
|
||||
('размещение сайта|адрес расположения серверов|адрес.*сервер.*сайт', 'site_hosting'),
|
||||
('адрес.*хостинг|адрес.*располож.*сервер', 'site_hosting_address'),
|
||||
('электронная почта', 'email'),
|
||||
('телефон', 'phone'),
|
||||
]
|
||||
|
||||
for row in ws.iter_rows(min_row=1, values_only=True):
|
||||
field_raw = clean_value(row[0]) if row[0] is not None else ''
|
||||
field = field_raw.lower()
|
||||
value = clean_value(row[1]) if len(row) > 1 else ''
|
||||
if not field:
|
||||
continue
|
||||
for keyword, key in field_map:
|
||||
if re.search(keyword, field):
|
||||
if value:
|
||||
data[key] = value
|
||||
log_fn(f" {keyword}: {value}")
|
||||
break
|
||||
|
||||
|
||||
def _import_152_is_list(wb, data, log_fn):
|
||||
"""Импорт информационных систем из остальных листов."""
|
||||
is_sheets = [sn for sn in wb.sheetnames if sn not in ('Общие сведения', 'Сотрудники')]
|
||||
if not is_sheets:
|
||||
log_fn("⚠ Листы ИС не найдены")
|
||||
return
|
||||
|
||||
is_list = []
|
||||
for sheet_name in is_sheets:
|
||||
ws = wb[sheet_name]
|
||||
rows = []
|
||||
for row in ws.iter_rows(min_row=1, values_only=True):
|
||||
vals = [clean_value(c) for c in row]
|
||||
field = (vals[0] or '').strip() if vals else ''
|
||||
if not field:
|
||||
continue
|
||||
rows.append(vals)
|
||||
|
||||
if len(rows) < 2:
|
||||
continue
|
||||
|
||||
is_obj = {
|
||||
'name': '', 'description': '', 'room': '', 'employee': '', 'position': '',
|
||||
'personalDataList': [], 'pd_subjects_list': [], 'pd_actions': [],
|
||||
'usersList': [], 'isInternet': False, 'personalDataCount': 'менее 100 000',
|
||||
'defense_tools': [], 'processing_mode': '',
|
||||
}
|
||||
|
||||
current_section = 'header'
|
||||
pd_items_raw = []
|
||||
subjects_raw = []
|
||||
actions_raw = []
|
||||
|
||||
for vals in rows:
|
||||
field = (vals[0] or '').strip()
|
||||
field_lower = field.lower()
|
||||
val_b = clean_value(vals[1]) if len(vals) > 1 else ''
|
||||
is_checked = val_b.upper() in ('ДА', 'YES', '✓', '✔', '1', 'TRUE', 'ЕСТЬ')
|
||||
|
||||
if 'цель обработки пд' in field_lower or ('цель обработки' in field_lower and ':' in field):
|
||||
current_section = 'purpose'
|
||||
continue
|
||||
if 'перечисленные пдн принадлежат' in field_lower or 'принадлеж' in field_lower:
|
||||
current_section = 'subjects'
|
||||
continue
|
||||
if 'количество записей' in field_lower or 'количество субъект' in field_lower:
|
||||
if val_b:
|
||||
is_obj['personalDataCount'] = val_b
|
||||
continue
|
||||
if 'перечень действий с пд' in field_lower or ('действи' in field_lower and 'пд' in field_lower):
|
||||
current_section = 'actions'
|
||||
continue
|
||||
if 'способ обработки пд' in field_lower:
|
||||
if val_b:
|
||||
is_obj['processing_mode'] = val_b
|
||||
continue
|
||||
if 'интернет' in field_lower:
|
||||
is_obj['isInternet'] = is_checked
|
||||
continue
|
||||
|
||||
if current_section == 'header':
|
||||
if 'перечень пд' in field_lower:
|
||||
current_section = 'pd_items'
|
||||
elif 'название ис' in field_lower or 'наименование ис' in field_lower or 'наименование информационной' in field_lower:
|
||||
is_obj['name'] = val_b
|
||||
log_fn(f" ИС: {val_b}")
|
||||
elif 'сотрудник' in field_lower and 'должност' not in field_lower:
|
||||
is_obj['employee'] = val_b
|
||||
if val_b:
|
||||
parts = [p.strip() for p in val_b.replace('\n', ';').split(';') if p.strip()]
|
||||
for part in parts:
|
||||
cleaned = part.strip()
|
||||
if ':' in cleaned and len(cleaned.split(':')[0]) < 20:
|
||||
cleaned = cleaned.split(':', 1)[1].strip()
|
||||
if cleaned and cleaned not in is_obj['usersList']:
|
||||
is_obj['usersList'].append(cleaned)
|
||||
elif 'должность сотрудника' in field_lower:
|
||||
is_obj['position'] = val_b
|
||||
elif 'наименование отдела' in field_lower:
|
||||
pass
|
||||
elif 'номер кабинета' in field_lower:
|
||||
is_obj['room'] = val_b
|
||||
elif 'значение' in field_lower or 'примечание' in field_lower:
|
||||
continue
|
||||
elif val_b and 'выбрать' not in field_lower:
|
||||
if not is_obj['description']:
|
||||
is_obj['description'] = f"{field}: {val_b}"
|
||||
else:
|
||||
is_obj['description'] += f"\n{field}: {val_b}"
|
||||
elif current_section == 'pd_items':
|
||||
if is_checked and 'выбрать' not in field_lower and field_lower != 'значение':
|
||||
pd_items_raw.append(field)
|
||||
if 'цель обработки' in field_lower:
|
||||
current_section = 'purpose'
|
||||
elif current_section == 'purpose':
|
||||
purpose_val = field or val_b or ''
|
||||
if purpose_val and 'выбрать' not in purpose_val.lower():
|
||||
is_obj['purpose'] = purpose_val
|
||||
log_fn(f" Цель: {purpose_val[:80]}")
|
||||
current_section = 'header'
|
||||
elif current_section == 'subjects':
|
||||
if is_checked and 'выбрать' not in field_lower:
|
||||
subjects_raw.append(field)
|
||||
if 'количество записей' in field_lower or 'действи' in field_lower:
|
||||
continue
|
||||
elif current_section == 'actions':
|
||||
if is_checked and 'выбрать' not in field_lower and 'защита' not in field_lower[:20]:
|
||||
actions_raw.append(field)
|
||||
if 'защита' in field_lower and 'действи' not in field_lower:
|
||||
current_section = 'defense'
|
||||
elif current_section == 'defense':
|
||||
defense_keywords = {
|
||||
'антивирус': 'Антивирус',
|
||||
'крипто': 'СКЗИ',
|
||||
'межсетев': 'Межсетевой экран',
|
||||
'сзи.*от несанкционирован': 'СЗИ от НСД',
|
||||
'обнаружен.*вторжен': 'СОВ',
|
||||
'программные.*средств.*модул': 'Доверенная загрузка',
|
||||
'доверен.*загрузк': 'Доверенная загрузка',
|
||||
'гарантирован': 'Доверенная загрузка',
|
||||
}
|
||||
matched_label = None
|
||||
for kw, label in defense_keywords.items():
|
||||
if re.search(kw, field_lower):
|
||||
matched_label = label
|
||||
break
|
||||
if matched_label and val_b and 'выбрать' not in field_lower:
|
||||
is_obj['defense_tools'].append(f"{matched_label}: {val_b}")
|
||||
elif matched_label:
|
||||
pass
|
||||
elif 'куда' in field_lower or 'передаются' in field_lower:
|
||||
current_section = 'header'
|
||||
elif 'способ обработки' in field_lower or 'локальную сеть' in field_lower or 'интернет' in field_lower:
|
||||
current_section = 'header'
|
||||
|
||||
if pd_items_raw:
|
||||
is_obj['personalDataList'] = pd_items_raw
|
||||
log_fn(f" ПДн: {len(pd_items_raw)}")
|
||||
if subjects_raw:
|
||||
is_obj['pd_subjects_list'] = subjects_raw
|
||||
log_fn(f" Субъектов: {len(subjects_raw)}")
|
||||
if actions_raw:
|
||||
is_obj['pd_actions'] = actions_raw
|
||||
log_fn(f" Действий: {len(actions_raw)}")
|
||||
|
||||
if not is_obj['name']:
|
||||
is_obj['name'] = sheet_name.strip()
|
||||
log_fn(f" ⚠ ИС без названия — использовано имя листа: {is_obj['name']}")
|
||||
|
||||
# Автоопределение категорий ПДн
|
||||
pd_cats = []
|
||||
all_pd_text = ' '.join(p.lower() for p in pd_items_raw)
|
||||
special_kw = ['состоян.*здоров', 'национальн', 'политическ', 'религиозн', 'философ', 'судимост', 'интимн']
|
||||
if any(re.search(kw, all_pd_text) for kw in special_kw):
|
||||
pd_cats.append('специальные')
|
||||
bio_kw = ['биометрическ', 'изображен.*лиц', 'голос.*человек', 'папилляр', 'дактилоскоп', 'фото.*изображен']
|
||||
if any(re.search(kw, all_pd_text) for kw in bio_kw):
|
||||
pd_cats.append('биометрические')
|
||||
if pd_items_raw:
|
||||
pd_cats.append('иные')
|
||||
if pd_cats:
|
||||
is_obj['personalDataCategory'] = pd_cats
|
||||
log_fn(f" Категории ПДн: {', '.join(pd_cats)}")
|
||||
|
||||
is_list.append(is_obj)
|
||||
log_fn(f" ✅ {is_obj['name']} — импортирована")
|
||||
|
||||
if is_list:
|
||||
data['informationSystems'] = is_list
|
||||
log_fn(f"\n✅ Всего импортировано ИС: {len(is_list)}")
|
||||
|
||||
|
||||
def _import_152_employees(wb, data, log_fn):
|
||||
"""Импорт комиссии и сотрудников из листа 'Сотрудники'."""
|
||||
sheet_name = None
|
||||
for sn in wb.sheetnames:
|
||||
if 'сотруд' in sn.lower():
|
||||
sheet_name = sn
|
||||
break
|
||||
if not sheet_name:
|
||||
log_fn("⚠ Лист «Сотрудники» не найден")
|
||||
return
|
||||
|
||||
ws = wb[sheet_name]
|
||||
rows = []
|
||||
for row in ws.iter_rows(min_row=1, values_only=True):
|
||||
vals = [clean_value(c) for c in row]
|
||||
if not vals or not vals[0]:
|
||||
continue
|
||||
rows.append(vals)
|
||||
|
||||
if not rows:
|
||||
log_fn("⚠ Лист «Сотрудники» пуст")
|
||||
return
|
||||
|
||||
commission = []
|
||||
in_commission = False
|
||||
COMMISSION_ROLES = {
|
||||
'председатель': 'Председатель комиссии',
|
||||
'секретарь': 'Секретарь комиссии',
|
||||
'заместитель председателя': 'Заместитель председателя комиссии',
|
||||
}
|
||||
employees = []
|
||||
in_employees = False
|
||||
|
||||
for vals in rows:
|
||||
col_a = (vals[0] or '').strip()
|
||||
col_b = clean_value(vals[1]) if len(vals) > 1 else ''
|
||||
col_c = clean_value(vals[2]) if len(vals) > 2 else ''
|
||||
col_d = clean_value(vals[3]) if len(vals) > 3 else ''
|
||||
col_e = clean_value(vals[4]) if len(vals) > 4 else ''
|
||||
a_lower = col_a.lower()
|
||||
|
||||
if 'состав комиссии' in a_lower:
|
||||
in_commission = True
|
||||
in_employees = False
|
||||
log_fn(" Секция: состав комиссии")
|
||||
continue
|
||||
if 'перечень сотрудников' in a_lower:
|
||||
in_commission = False
|
||||
in_employees = True
|
||||
log_fn(" Секция: сотрудники с доступом к ПДн")
|
||||
continue
|
||||
if a_lower in ('№ п/п', '№', 'номер', ''):
|
||||
continue
|
||||
|
||||
if in_commission:
|
||||
role = 'Член комиссии'
|
||||
if col_e:
|
||||
e_lower = col_e.lower()
|
||||
for keyword, role_name in COMMISSION_ROLES.items():
|
||||
if keyword in e_lower:
|
||||
role = role_name
|
||||
break
|
||||
if not col_b and not col_c:
|
||||
continue
|
||||
commission.append({
|
||||
'role': role,
|
||||
'position': col_b,
|
||||
'fio': col_c.replace('\n', ' '),
|
||||
})
|
||||
|
||||
if in_employees:
|
||||
fio = col_c if col_c else col_d
|
||||
if not fio:
|
||||
continue
|
||||
employee = {
|
||||
'position': col_b,
|
||||
'fio': fio.replace('\n', ' '),
|
||||
}
|
||||
if col_e:
|
||||
employee['is_list'] = [s.strip() for s in col_e.split('\n') if s.strip()]
|
||||
employees.append(employee)
|
||||
|
||||
if commission:
|
||||
commission = [c for c in commission if c['position'] or c['fio']]
|
||||
data['commission'] = commission
|
||||
log_fn(f" Комиссия: {len(commission)} чел.")
|
||||
for m in commission[:3]:
|
||||
log_fn(f" {m['role']}: {m['position'][:40]} — {m['fio'][:40]}")
|
||||
|
||||
if employees:
|
||||
data['employeesAccess'] = employees
|
||||
log_fn(f" Сотрудников с доступом: {len(employees)}")
|
||||
|
||||
|
||||
def _validate_company_data(data, log_fn):
|
||||
"""Проверка обязательных полей."""
|
||||
required = ['fullName', 'inn']
|
||||
missing = [f for f in required if not data.get(f)]
|
||||
if missing:
|
||||
log_fn(f"⚠️ ОБЯЗАТЕЛЬНЫЕ ПОЛЯ НЕ ЗАПОЛНЕНЫ: {', '.join(missing)}")
|
||||
else:
|
||||
log_fn("✅ Все обязательные поля заполнены")
|
||||
|
||||
inn = (data.get('inn') or '').replace(' ', '')
|
||||
if inn and (not inn.isdigit() or len(inn) not in (10, 12)):
|
||||
log_fn(f"⚠️ ИНН подозрительного формата: «{inn}»")
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DokoGen — Генератор документов из шаблонов DOCX"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import io
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Callable
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
from lxml import etree
|
||||
except ImportError:
|
||||
Document = None
|
||||
|
||||
from .variables import OLD_VAR_MAP, OLD_VAR_PATTERN, VAR_PATTERN
|
||||
|
||||
# Стандартный шаблон для незаполненных переменных
|
||||
EMPTY_MARKER = '—'
|
||||
|
||||
|
||||
def process_template(
|
||||
template_path: str,
|
||||
replacements: Dict[str, str],
|
||||
output_path: Optional[str] = None,
|
||||
log_func: Optional[Callable] = None,
|
||||
) -> str:
|
||||
"""Обрабатывает шаблон DOCX: подстановка переменных, циклы, сохранение."""
|
||||
if Document is None:
|
||||
raise ImportError("python-docx не установлен. Установите: pip install python-docx")
|
||||
|
||||
if not os.path.exists(template_path):
|
||||
raise FileNotFoundError(f"Шаблон не найден: {template_path}")
|
||||
|
||||
if output_path is None:
|
||||
base, ext = os.path.splitext(template_path)
|
||||
output_path = f"{base}_result{ext}"
|
||||
|
||||
doc = Document(template_path)
|
||||
|
||||
# 1. Миграция старых переменных <...> → {{...}}
|
||||
_migrate_old_vars(doc)
|
||||
|
||||
# 2. Удаление proofErr
|
||||
_strip_prooferr(doc)
|
||||
|
||||
# 3. Обработка циклов (loop/loop_end)
|
||||
loops = replacements.pop('_loops', {})
|
||||
if loops:
|
||||
_process_loops(doc, loops)
|
||||
|
||||
# 4. Перезагрузка после циклов
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
buf.seek(0)
|
||||
doc = Document(buf)
|
||||
|
||||
# 5. Плоская замена переменных (2 прохода)
|
||||
for _ in range(2):
|
||||
_replace_in_paragraphs(doc.paragraphs, replacements)
|
||||
_replace_in_tables(doc.tables, replacements)
|
||||
_replace_in_headers_footers(doc, replacements)
|
||||
|
||||
# 6. Подсветка незаполненных
|
||||
_highlight_unmatched(doc)
|
||||
|
||||
# 7. Сохранение
|
||||
doc.save(output_path)
|
||||
|
||||
if log_func:
|
||||
log_func(f"✅ {os.path.basename(output_path)}")
|
||||
return output_path
|
||||
|
||||
|
||||
# ==================== МИГРАЦИЯ СТАРЫХ ПЕРЕМЕННЫХ ====================
|
||||
|
||||
def _migrate_old_vars(doc):
|
||||
"""Заменяет <OldVar> на {{new_var}} в документе."""
|
||||
def _migrate_text(text):
|
||||
return OLD_VAR_PATTERN.sub(lambda m: OLD_VAR_MAP.get(m.group(0), m.group(0)), text)
|
||||
|
||||
for para in doc.paragraphs:
|
||||
new_text = _migrate_text(para.text)
|
||||
if new_text != para.text:
|
||||
if para.runs:
|
||||
first = para.runs[0]
|
||||
para.clear()
|
||||
run = para.add_run(new_text)
|
||||
_copy_run_style(first, run)
|
||||
else:
|
||||
para.clear()
|
||||
para.add_run(new_text)
|
||||
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
new_text = _migrate_text(para.text)
|
||||
if new_text != para.text:
|
||||
if para.runs:
|
||||
first = para.runs[0]
|
||||
para.clear()
|
||||
run = para.add_run(new_text)
|
||||
_copy_run_style(first, run)
|
||||
else:
|
||||
para.clear()
|
||||
para.add_run(new_text)
|
||||
|
||||
for section in doc.sections:
|
||||
for hf in [section.header, section.footer,
|
||||
section.first_page_header, section.first_page_footer]:
|
||||
if hf:
|
||||
for para in hf.paragraphs:
|
||||
new_text = _migrate_text(para.text)
|
||||
if new_text != para.text:
|
||||
if para.runs:
|
||||
first = para.runs[0]
|
||||
para.clear()
|
||||
run = para.add_run(new_text)
|
||||
_copy_run_style(first, run)
|
||||
else:
|
||||
para.clear()
|
||||
para.add_run(new_text)
|
||||
|
||||
|
||||
# ==================== ЗАМЕНА В ЭЛЕМЕНТАХ ====================
|
||||
|
||||
def _replace_in_paragraphs(paragraphs, replacements):
|
||||
for para in paragraphs:
|
||||
full = para.text
|
||||
# Пропускаем строки циклов
|
||||
if 'loop:' in full or 'loop_end' in full:
|
||||
continue
|
||||
matches = VAR_PATTERN.findall(full)
|
||||
if not matches:
|
||||
continue
|
||||
new_text = full
|
||||
for var in matches:
|
||||
val = replacements.get(var)
|
||||
if val is not None:
|
||||
new_text = new_text.replace(var, str(val))
|
||||
if new_text != full:
|
||||
if para.runs:
|
||||
first = para.runs[0]
|
||||
para.clear()
|
||||
run = para.add_run(new_text)
|
||||
_copy_run_style(first, run)
|
||||
else:
|
||||
para.clear()
|
||||
para.add_run(new_text)
|
||||
|
||||
|
||||
def _replace_in_tables(tables, replacements):
|
||||
for table in tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
full = para.text
|
||||
matches = VAR_PATTERN.findall(full)
|
||||
if not matches:
|
||||
continue
|
||||
new_text = full
|
||||
for var in matches:
|
||||
val = replacements.get(var)
|
||||
if val is not None:
|
||||
new_text = new_text.replace(var, str(val))
|
||||
if new_text != full:
|
||||
if para.runs:
|
||||
first = para.runs[0]
|
||||
para.clear()
|
||||
run = para.add_run(new_text)
|
||||
_copy_run_style(first, run)
|
||||
else:
|
||||
para.clear()
|
||||
para.add_run(new_text)
|
||||
|
||||
|
||||
def _replace_in_headers_footers(doc, replacements):
|
||||
for section in doc.sections:
|
||||
for hf in [section.header, section.footer,
|
||||
section.first_page_header, section.first_page_footer]:
|
||||
if hf:
|
||||
_replace_in_paragraphs(hf.paragraphs, replacements)
|
||||
|
||||
|
||||
def _copy_run_style(source, target):
|
||||
"""Копирует форматирование из одного run в другой."""
|
||||
try:
|
||||
target.bold = source.bold
|
||||
target.italic = source.italic
|
||||
target.underline = source.underline
|
||||
if source.font:
|
||||
target.font.size = source.font.size
|
||||
target.font.name = source.font.name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _strip_prooferr(doc):
|
||||
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
for elem in doc.element.iter():
|
||||
for child in list(elem):
|
||||
if child.tag == f'{{{ns}}}proofErr':
|
||||
elem.remove(child)
|
||||
|
||||
|
||||
# ==================== ОБРАБОТКА ЦИКЛОВ ====================
|
||||
|
||||
LOOP_START = re.compile(r'(?:<!--|<!--)?\s*loop:(\w+)(?:\s*(?:-->|-->))?')
|
||||
LOOP_END = re.compile(r'(?:<!--|<!--)?\s*loop_end(?:\s*(?:-->|-->))?')
|
||||
|
||||
|
||||
def _process_loops(doc, loops: Dict[str, List[Dict]]):
|
||||
body = doc.element.body
|
||||
max_passes = 20
|
||||
for _ in range(max_passes):
|
||||
body_str = etree.tostring(body, encoding='unicode')
|
||||
# Находим все ключи циклов
|
||||
found = set(LOOP_START.findall(body_str))
|
||||
if not found:
|
||||
break
|
||||
changed = False
|
||||
for key in sorted(found):
|
||||
items = loops.get(key, [])
|
||||
if not items:
|
||||
_remove_loop_block(body, key)
|
||||
changed = True
|
||||
else:
|
||||
_expand_loop(body, key, items)
|
||||
changed = True
|
||||
body = doc.element.body
|
||||
if not changed:
|
||||
break
|
||||
|
||||
|
||||
def _expand_loop(body, key: str, items: List[Dict]):
|
||||
body_str = etree.tostring(body, encoding='unicode')
|
||||
|
||||
markers = []
|
||||
for m in LOOP_START.finditer(body_str):
|
||||
if m.group(1) == key:
|
||||
markers.append((m.start(), True, m.end()))
|
||||
for m in LOOP_END.finditer(body_str):
|
||||
markers.append((m.start(), False, m.end()))
|
||||
|
||||
if len(markers) < 2:
|
||||
return
|
||||
markers.sort()
|
||||
|
||||
start_pos = end_pos = start_end = end_end = None
|
||||
for pos, is_start, end in markers:
|
||||
if is_start and start_pos is None:
|
||||
start_pos = pos
|
||||
start_end = end
|
||||
elif not is_start and start_pos is not None:
|
||||
end_pos = pos
|
||||
end_end = end
|
||||
break
|
||||
if start_pos is None or end_pos is None:
|
||||
return
|
||||
|
||||
# Определяем структурный элемент (параграф или строка таблицы)
|
||||
s_start, s_end = _find_element_bounds(body_str, start_pos)
|
||||
e_start, e_end = _find_element_bounds(body_str, end_pos)
|
||||
|
||||
if s_start is None or e_end is None:
|
||||
return
|
||||
|
||||
# Извлекаем шаблон (содержимое между маркерами)
|
||||
template_xml = body_str[start_end:end_pos]
|
||||
# Убираем остатки маркеров
|
||||
template_xml = LOOP_START.sub('', template_xml)
|
||||
template_xml = LOOP_END.sub('', template_xml)
|
||||
|
||||
# Размножаем
|
||||
expanded = []
|
||||
for idx, item in enumerate(items, 1):
|
||||
clone = template_xml
|
||||
for field, value in item.items():
|
||||
clone = clone.replace(f'{{{{item.{field}}}}}', str(value) if value else EMPTY_MARKER)
|
||||
clone = clone.replace(f'{{{{{field}}}}}', str(value) if value else EMPTY_MARKER)
|
||||
clone = clone.replace('{{item.number}}', str(idx))
|
||||
clone = clone.replace('{{number}}', str(idx))
|
||||
clone = clone.replace('\u27f5BR\u27f5', '</w:t><w:br/><w:t xml:space="preserve">')
|
||||
expanded.append(clone)
|
||||
|
||||
new_str = body_str[:s_start] + ''.join(expanded) + body_str[e_end:]
|
||||
try:
|
||||
new_body = etree.fromstring(new_str.encode('utf-8'))
|
||||
parent = body.getparent()
|
||||
if parent is not None:
|
||||
parent.replace(body, new_body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _remove_loop_block(body, key: str):
|
||||
body_str = etree.tostring(body, encoding='unicode')
|
||||
start_pos = None
|
||||
for m in LOOP_START.finditer(body_str):
|
||||
if m.group(1) == key:
|
||||
start_pos = m.start()
|
||||
break
|
||||
if start_pos is None:
|
||||
return
|
||||
s_start, s_end = _find_element_bounds(body_str, start_pos)
|
||||
end_match = LOOP_END.search(body_str, start_pos)
|
||||
if end_match:
|
||||
_, e_end = _find_element_bounds(body_str, end_match.start())
|
||||
new_str = body_str[:s_start] + body_str[e_end:]
|
||||
else:
|
||||
new_str = body_str[:s_start] + body_str[s_end:]
|
||||
try:
|
||||
new_body = etree.fromstring(new_str.encode('utf-8'))
|
||||
parent = body.getparent()
|
||||
if parent is not None:
|
||||
parent.replace(body, new_body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _find_element_bounds(xml_str: str, pos: int):
|
||||
"""Находит границы элемента (table row или paragraph), содержащего позицию."""
|
||||
# Пробуем tr
|
||||
tr_open = max(xml_str.rfind('<w:tr ', 0, pos), xml_str.rfind('<w:tr>', 0, pos))
|
||||
if tr_open != -1:
|
||||
tr_close = xml_str.find('</w:tr>', pos)
|
||||
if tr_close != -1:
|
||||
return tr_open, tr_close + len('</w:tr>')
|
||||
# Пробуем p
|
||||
p_open = max(xml_str.rfind('<w:p ', 0, pos), xml_str.rfind('<w:p>', 0, pos))
|
||||
if p_open != -1:
|
||||
p_close = xml_str.find('</w:p>', pos)
|
||||
if p_close != -1:
|
||||
return p_open, p_close + len('</w:p>')
|
||||
return None, None
|
||||
|
||||
|
||||
# ==================== ПОДСВЕТКА НЕЗАПОЛНЕННЫХ ====================
|
||||
|
||||
def _highlight_unmatched(doc):
|
||||
"""Подсвечивает незаполненные переменные красным."""
|
||||
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
|
||||
def _highlight_run(run):
|
||||
rpr = run._element.find(f'{{{ns}}}rPr')
|
||||
if rpr is None:
|
||||
rpr = OxmlElement('w:rPr')
|
||||
run._element.insert(0, rpr)
|
||||
color = OxmlElement('w:color')
|
||||
color.set(f'{{{ns}}}val', 'FF0000')
|
||||
rpr.append(color)
|
||||
sz = OxmlElement('w:sz')
|
||||
sz.set(f'{{{ns}}}val', '22')
|
||||
rpr.append(sz)
|
||||
|
||||
for para in doc.paragraphs:
|
||||
for run in para.runs:
|
||||
if '{{' in run.text and '}}' in run.text:
|
||||
_highlight_run(run)
|
||||
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
for run in para.runs:
|
||||
if '{{' in run.text and '}}' in run.text:
|
||||
_highlight_run(run)
|
||||
|
||||
for section in doc.sections:
|
||||
for hf in [section.header, section.footer]:
|
||||
if hf:
|
||||
for para in hf.paragraphs:
|
||||
for run in para.runs:
|
||||
if '{{' in run.text and '}}' in run.text:
|
||||
_highlight_run(run)
|
||||
@@ -1,11 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DokoGen — Генератор документов 152-ФЗ
|
||||
Точка входа: python main.py
|
||||
Точка входа: python3 main.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Путь к проекту
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from dokogen.ui import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -18,6 +18,7 @@ class InformationSystem:
|
||||
"""Информационная система (обработка ПДн)"""
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
software: str = ""
|
||||
is_local_network: bool = False
|
||||
is_internet: bool = False
|
||||
defence_level: str = ""
|
||||
@@ -28,8 +29,6 @@ class InformationSystem:
|
||||
users: str = ""
|
||||
defense_tools_list: List[str] = field(default_factory=list)
|
||||
processing_modes: str = ""
|
||||
subject_type: str = "работники организации"
|
||||
pd_actions_list: List[str] = field(default_factory=list)
|
||||
pd_subjects_list: List[str] = field(default_factory=list)
|
||||
personal_data_list: List[str] = field(default_factory=list)
|
||||
users_list: List[str] = field(default_factory=list)
|
||||
@@ -90,7 +89,6 @@ class Company:
|
||||
commission: List[CommissionMember] = field(default_factory=list)
|
||||
information_systems: List[InformationSystem] = field(default_factory=list)
|
||||
paper_documents_list: List[PaperDocument] = field(default_factory=list)
|
||||
employees_access: List[Dict[str, str]] = field(default_factory=list)
|
||||
|
||||
def update_declensions(self, inflect_func):
|
||||
"""Обновить склонения полного названия"""
|
||||
@@ -135,8 +133,6 @@ class Company:
|
||||
PaperDocument(**p) if isinstance(p, dict) else p
|
||||
for p in d["paper_documents_list"]
|
||||
]
|
||||
if "employees_access" in d and not isinstance(d["employees_access"], list):
|
||||
d["employees_access"] = []
|
||||
valid = {f.name for f in cls.__dataclass_fields__.values()}
|
||||
return cls(**{k: v for k, v in d.items() if k in valid})
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DokoGen — test: verify generator with nested loops"""
|
||||
|
||||
import sys, os, json, tempfile, zipfile, shutil, re
|
||||
from datetime import datetime
|
||||
|
||||
# Add to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from dokogen.models import Company, CommissionMember, InformationSystem
|
||||
from dokogen.variables import build_replacements
|
||||
from dokogen.generator import process_template, expand_loops_in_zip
|
||||
|
||||
def create_test_template():
|
||||
"""Creates a test .docx with nested loops for verification."""
|
||||
from docx import Document
|
||||
from docx.shared import Pt
|
||||
|
||||
doc = Document()
|
||||
|
||||
# Title
|
||||
doc.add_heading('Test Document with Nested Loops', 0)
|
||||
|
||||
# Simple variable
|
||||
doc.add_paragraph('Company: {{company_full_name}}')
|
||||
doc.add_paragraph('Date: {{date}}')
|
||||
doc.add_paragraph('')
|
||||
|
||||
# Outer loop: information_systems
|
||||
doc.add_paragraph('<!-- loop:information_systems -->')
|
||||
doc.add_paragraph('=== IS: {{item.name}} ===')
|
||||
doc.add_paragraph('Category: {{item.category}}')
|
||||
doc.add_paragraph('Threat type: {{item.threat_type}}')
|
||||
doc.add_paragraph('Defence level: {{item.defence_level}}')
|
||||
doc.add_paragraph('')
|
||||
|
||||
# Inner loop: commission
|
||||
doc.add_paragraph('Commission members:')
|
||||
doc.add_paragraph('<!-- loop:commission -->')
|
||||
doc.add_paragraph(' - {{item.role}}: {{item.position}} {{item.fio}}')
|
||||
doc.add_paragraph('<!-- loop_end -->')
|
||||
|
||||
doc.add_paragraph('')
|
||||
doc.add_paragraph('--- end of IS ---')
|
||||
doc.add_paragraph('')
|
||||
doc.add_paragraph('<!-- loop_end -->')
|
||||
|
||||
doc.add_paragraph('')
|
||||
doc.add_paragraph('Footer: Generated automatically')
|
||||
|
||||
path = '/tmp/test_template_nested.docx'
|
||||
doc.save(path)
|
||||
print(f'✅ Test template created: {path}')
|
||||
return path
|
||||
|
||||
def test_zip_loop_expansion():
|
||||
"""Test ZIP-level loop expansion with mock data."""
|
||||
print('\n' + '='*60)
|
||||
print('TEST: ZIP-level loop expansion')
|
||||
print('='*60)
|
||||
|
||||
# Create test data
|
||||
company = Company(
|
||||
full_name='ООО "Ромашка"',
|
||||
short_name='Ромашка',
|
||||
inn='7701234567',
|
||||
chief_fio='Иванов Иван Иванович',
|
||||
chief_position='Генеральный директор',
|
||||
commission=[
|
||||
CommissionMember(role='Председатель', position='Директор', fio='Иванов И.И.'),
|
||||
CommissionMember(role='Секретарь', position='Секретарь', fio='Петров П.П.'),
|
||||
CommissionMember(role='Член комиссии', position='Бухгалтер', fio='Сидорова А.А.'),
|
||||
],
|
||||
information_systems=[
|
||||
InformationSystem(name='ИС-1 "Бухгалтерия"', category='специальные',
|
||||
pd_count='менее 100 000', threat_type='1', defence_level='2'),
|
||||
InformationSystem(name='ИС-2 "Кадры"', category='иные',
|
||||
pd_count='более 100 000', threat_type='3', defence_level='4'),
|
||||
InformationSystem(name='ИС-3 "Склад"', category='общедоступные',
|
||||
pd_count='менее 100 000', threat_type='2', defence_level='3'),
|
||||
]
|
||||
)
|
||||
|
||||
# Build replacements
|
||||
replacements = build_replacements(
|
||||
company=company,
|
||||
is_list=company.information_systems,
|
||||
doc_number='001',
|
||||
doc_date='31.07.2026',
|
||||
direction='152fz'
|
||||
)
|
||||
|
||||
loops = replacements.get('_loops', {})
|
||||
print(f'\nLoops found: {list(loops.keys())}')
|
||||
print(f'IS items: {len(loops.get("information_systems", []))}')
|
||||
print(f'Commission items: {len(loops.get("commission", []))}')
|
||||
|
||||
# Create test template
|
||||
template_path = create_test_template()
|
||||
|
||||
# Verify template has markers
|
||||
with zipfile.ZipFile(template_path, 'r') as z:
|
||||
doc_xml = z.read('word/document.xml').decode('utf-8')
|
||||
|
||||
start_count = doc_xml.count('loop:')
|
||||
end_count = doc_xml.count('loop_end')
|
||||
print(f'\nMarkers in template: {start_count} starts, {end_count} ends')
|
||||
|
||||
# Process with ZIP-level loop expansion
|
||||
print('\nProcessing loops via ZIP...')
|
||||
result_path = expand_loops_in_zip(template_path, loops)
|
||||
|
||||
# Verify result
|
||||
with zipfile.ZipFile(result_path, 'r') as z:
|
||||
result_xml = z.read('word/document.xml').decode('utf-8')
|
||||
|
||||
remaining_starts = len(re.findall(r'loop:(\w+)', result_xml))
|
||||
remaining_ends = result_xml.count('loop_end')
|
||||
|
||||
print(f'Markers after expansion: {remaining_starts} starts, {remaining_ends} ends')
|
||||
|
||||
if remaining_starts == 0 and remaining_ends == 0:
|
||||
print('✅ ALL LOOPS EXPANDED SUCCESSFULLY')
|
||||
else:
|
||||
print(f'⚠️ {remaining_starts} loop markers remaining')
|
||||
|
||||
# Now test full process_template
|
||||
print('\n' + '='*60)
|
||||
print('TEST: Full process_template with all replacements')
|
||||
print('='*60)
|
||||
|
||||
# Rebuild replacements (they were consumed)
|
||||
replacements2 = build_replacements(
|
||||
company=company,
|
||||
is_list=company.information_systems,
|
||||
doc_number='001',
|
||||
doc_date='31.07.2026',
|
||||
direction='152fz'
|
||||
)
|
||||
|
||||
try:
|
||||
output_path = process_template(
|
||||
template_path=template_path,
|
||||
replacements=replacements2,
|
||||
output_path='/tmp/test_output.docx',
|
||||
log_func=print
|
||||
)
|
||||
print(f'\n✅ Output generated: {output_path}')
|
||||
|
||||
# Verify output
|
||||
with zipfile.ZipFile(output_path, 'r') as z:
|
||||
out_xml = z.read('word/document.xml').decode('utf-8')
|
||||
|
||||
# Check no remaining variables
|
||||
remaining_vars = re.findall(r'\{\{[^}]+\}\}', out_xml)
|
||||
remaining_old = re.findall(r'<[A-Za-z_]+>', out_xml)
|
||||
|
||||
if remaining_vars:
|
||||
print(f'⚠️ Remaining {{...}} vars: {remaining_vars[:5]}')
|
||||
else:
|
||||
print('✅ No remaining {{...}} variables')
|
||||
|
||||
if remaining_old:
|
||||
print(f'⚠️ Remaining <...> vars: {remaining_old[:5]}')
|
||||
else:
|
||||
print('✅ No remaining <...> variables')
|
||||
|
||||
# Check IS names appear
|
||||
for isys in company.information_systems:
|
||||
if isys.name in out_xml:
|
||||
print(f'✅ Found IS name: {isys.name}')
|
||||
else:
|
||||
print(f'⚠️ MISSING IS name: {isys.name}')
|
||||
|
||||
# Check commission members appear
|
||||
for m in company.commission:
|
||||
if m.fio in out_xml:
|
||||
print(f'✅ Found commission: {m.fio}')
|
||||
else:
|
||||
print(f'⚠️ MISSING commission: {m.fio}')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Cleanup
|
||||
os.unlink(template_path)
|
||||
if os.path.exists(result_path):
|
||||
os.unlink(result_path)
|
||||
if os.path.exists('/tmp/test_output.docx'):
|
||||
os.unlink('/tmp/test_output.docx')
|
||||
|
||||
print('\n' + '='*60)
|
||||
print('TEST COMPLETE')
|
||||
print('='*60)
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_zip_loop_expansion()
|
||||
+27
-546
@@ -39,8 +39,8 @@ class DokoGenApp:
|
||||
)
|
||||
|
||||
# Переменные
|
||||
self.doc_number_var = tk.StringVar(value="")
|
||||
self.doc_date_var = tk.StringVar(value="")
|
||||
self.doc_number_var = tk.StringVar(value="001")
|
||||
self.doc_date_var = tk.StringVar(value=datetime.now().strftime("%d.%m.%Y"))
|
||||
self.templates_var = tk.StringVar()
|
||||
self.save_var = tk.StringVar()
|
||||
self.status_var = tk.StringVar(value="Готов")
|
||||
@@ -110,7 +110,6 @@ class DokoGenApp:
|
||||
self._build_company_tab()
|
||||
self._build_is_tab()
|
||||
self._build_commission_tab()
|
||||
self._build_users_tab()
|
||||
self._build_generation_tab()
|
||||
self._build_log_tab()
|
||||
|
||||
@@ -212,12 +211,6 @@ class DokoGenApp:
|
||||
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)
|
||||
|
||||
# --- Должностные лица ---
|
||||
@@ -284,9 +277,13 @@ class DokoGenApp:
|
||||
self.is_description_entry = ttk.Entry(fields_frame, width=55)
|
||||
self.is_description_entry.grid(row=1, column=1, padx=10, sticky=tk.EW)
|
||||
|
||||
ttk.Label(fields_frame, text="ПО:").grid(row=2, column=0, sticky=tk.W, pady=2)
|
||||
self.is_software_entry = ttk.Entry(fields_frame, width=55)
|
||||
self.is_software_entry.grid(row=2, column=1, padx=10, sticky=tk.EW)
|
||||
|
||||
# Чекбоксы
|
||||
chk_frame = ttk.Frame(fields_frame)
|
||||
chk_frame.grid(row=2, column=1, sticky=tk.W, pady=5)
|
||||
chk_frame.grid(row=3, column=1, sticky=tk.W, pady=5)
|
||||
self.is_lan_var = tk.BooleanVar()
|
||||
ttk.Checkbutton(chk_frame, text="ЛВС", variable=self.is_lan_var).pack(side=tk.LEFT, padx=5)
|
||||
self.is_internet_var = tk.BooleanVar()
|
||||
@@ -294,47 +291,33 @@ class DokoGenApp:
|
||||
|
||||
# Тип угроз
|
||||
self.is_threat_var = tk.StringVar(value="3")
|
||||
self.is_threat_var.trace_add("write", lambda *_: self._on_categories_or_count_changed())
|
||||
ttk.Label(fields_frame, text="Тип угроз:").grid(row=3, column=0, sticky=tk.W, pady=2)
|
||||
ttk.Label(fields_frame, text="Тип угроз:").grid(row=4, column=0, sticky=tk.W, pady=2)
|
||||
threat_frame = ttk.Frame(fields_frame)
|
||||
threat_frame.grid(row=3, column=1, sticky=tk.W, padx=10)
|
||||
threat_frame.grid(row=4, column=1, sticky=tk.W, padx=10)
|
||||
for val, text in [("1", "1 (НДВ в СПО)"), ("2", "2 (НДВ в ППО)"), ("3", "3 (НДВ нет)")]:
|
||||
ttk.Radiobutton(threat_frame, text=text, variable=self.is_threat_var,
|
||||
value=val).pack(side=tk.LEFT, padx=3)
|
||||
|
||||
# Уровень защищённости
|
||||
self.is_defence_var = tk.StringVar(value="4")
|
||||
ttk.Label(fields_frame, text="Ур. защищённости:").grid(row=4, column=0, sticky=tk.W, pady=2)
|
||||
ttk.Label(fields_frame, text="Ур. защищённости:").grid(row=5, column=0, sticky=tk.W, pady=2)
|
||||
def_frame = ttk.Frame(fields_frame)
|
||||
def_frame.grid(row=4, column=1, sticky=tk.W, padx=10)
|
||||
def_frame.grid(row=5, column=1, sticky=tk.W, padx=10)
|
||||
for lvl in ["1", "2", "3", "4"]:
|
||||
ttk.Radiobutton(def_frame, text=f"{lvl} ур.",
|
||||
variable=self.is_defence_var, value=lvl).pack(side=tk.LEFT, padx=3)
|
||||
|
||||
|
||||
# Категории ПДн
|
||||
ttk.Label(fields_frame, text="Категории ПДн:").grid(row=5, column=0, sticky=tk.NW, pady=2)
|
||||
ttk.Label(fields_frame, text="Категории ПДн:").grid(row=6, column=0, sticky=tk.NW, pady=2)
|
||||
cat_frame = ttk.Frame(fields_frame)
|
||||
cat_frame.grid(row=5, column=1, sticky=tk.W, padx=10)
|
||||
cat_frame.grid(row=6, column=1, sticky=tk.W, padx=10)
|
||||
self.category_vars = []
|
||||
for cat in ["специальные", "биометрические", "общедоступные", "иные"]:
|
||||
var = tk.BooleanVar()
|
||||
var.trace_add('write', lambda *_: self._on_categories_or_count_changed())
|
||||
cb = ttk.Checkbutton(cat_frame, text=cat, variable=var)
|
||||
cb.pack(anchor=tk.W)
|
||||
self.category_vars.append((cat, var))
|
||||
|
||||
# Количество записей ПДн (выпадающий список)
|
||||
ttk.Label(fields_frame, text="Кол-во записей:").grid(row=6, column=0, sticky=tk.W, pady=2)
|
||||
self.is_pd_count_var = tk.StringVar(value="менее 100 000")
|
||||
self.is_pd_count_combo = ttk.Combobox(fields_frame, textvariable=self.is_pd_count_var,
|
||||
values=["менее 100 000", "более 100 000"],
|
||||
state='readonly', width=28)
|
||||
self.is_pd_count_combo.grid(row=6, column=1, padx=10, sticky=tk.W)
|
||||
self.is_pd_count_combo.bind('<<ComboboxSelected>>', lambda e: self._on_categories_or_count_changed())
|
||||
|
||||
|
||||
|
||||
fields_frame.columnconfigure(1, weight=1)
|
||||
|
||||
# Вложенные списки
|
||||
@@ -344,46 +327,23 @@ class DokoGenApp:
|
||||
# Список ПДн
|
||||
pd_tab = ttk.Frame(nb2)
|
||||
nb2.add(pd_tab, text="Перечень ПДн")
|
||||
pd_btn_frame = ttk.Frame(pd_tab)
|
||||
pd_btn_frame.pack(fill=tk.X)
|
||||
ttk.Button(pd_btn_frame, text="+ Добавить", command=self._add_pd_item).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(pd_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_listbox)).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(pd_btn_frame, text="Выбрать из списка", command=self._select_from_pd_list).pack(side=tk.LEFT, padx=2)
|
||||
self.pd_listbox = tk.Listbox(pd_tab, height=6)
|
||||
self.pd_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
ttk.Button(pd_tab, text="+ Добавить", command=self._add_pd_item).pack(pady=3)
|
||||
|
||||
# Субъекты ПДн
|
||||
subj_tab = ttk.Frame(nb2)
|
||||
nb2.add(subj_tab, text="Субъекты ПДн")
|
||||
subj_btn_frame = ttk.Frame(subj_tab)
|
||||
subj_btn_frame.pack(fill=tk.X)
|
||||
ttk.Button(subj_btn_frame, text="+ Добавить", command=self._add_pd_subjects_item).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(subj_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_subjects_listbox)).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(subj_btn_frame, text="Выбрать из списка", command=self._select_from_subjects_list).pack(side=tk.LEFT, padx=2)
|
||||
self.pd_subjects_listbox = tk.Listbox(subj_tab, height=6)
|
||||
self.pd_subjects_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
# Действия с ПДн
|
||||
act_tab = ttk.Frame(nb2)
|
||||
nb2.add(act_tab, text="Действия с ПДн")
|
||||
act_btn_frame = ttk.Frame(act_tab)
|
||||
act_btn_frame.pack(fill=tk.X)
|
||||
ttk.Button(act_btn_frame, text="+ Добавить", command=self._add_pd_action_item).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(act_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_actions_listbox)).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(act_btn_frame, text="Выбрать из списка", command=self._select_from_actions_list).pack(side=tk.LEFT, padx=2)
|
||||
self.pd_actions_listbox = tk.Listbox(act_tab, height=6)
|
||||
self.pd_actions_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
ttk.Button(subj_tab, text="+ Добавить", command=self._add_pd_subjects_item).pack(pady=3)
|
||||
|
||||
# СЗИ
|
||||
def_tab = ttk.Frame(nb2)
|
||||
nb2.add(def_tab, text="Средства защиты")
|
||||
def_btn_frame = ttk.Frame(def_tab)
|
||||
def_btn_frame.pack(fill=tk.X)
|
||||
ttk.Button(def_btn_frame, text="+ Добавить", command=self._add_defense_item).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(def_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.defense_tools_listbox)).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(def_btn_frame, text="Выбрать из списка", command=self._select_from_defense_list).pack(side=tk.LEFT, padx=2)
|
||||
self.defense_tools_listbox = tk.Listbox(def_tab, height=6)
|
||||
self.defense_tools_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
ttk.Button(def_tab, text="+ Добавить", command=self._add_defense_item).pack(pady=3)
|
||||
|
||||
# Пользователи
|
||||
user_tab = ttk.Frame(nb2)
|
||||
@@ -430,109 +390,6 @@ class DokoGenApp:
|
||||
ttk.Button(frame, text="+ Добавить члена комиссии",
|
||||
command=self._add_commission_row_ui).pack(pady=10)
|
||||
|
||||
# ---------- ВКЛАДКА "ПОЛЬЗОВАТЕЛИ" ----------
|
||||
def _build_users_tab(self):
|
||||
tab = ttk.Frame(self.notebook)
|
||||
self.notebook.add(tab, text="Пользователи")
|
||||
|
||||
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)
|
||||
@@ -608,18 +465,16 @@ class DokoGenApp:
|
||||
isys = self.model.information_systems[self._current_is_idx]
|
||||
isys.name = self.is_name_entry.get().strip()
|
||||
isys.description = self.is_description_entry.get().strip()
|
||||
isys.software = self.is_software_entry.get().strip()
|
||||
isys.is_local_network = self.is_lan_var.get()
|
||||
isys.is_internet = self.is_internet_var.get()
|
||||
isys.threat_type = self.is_threat_var.get()
|
||||
isys.defence_level = self.is_defence_var.get()
|
||||
cats = [c for c, v in self.category_vars if v.get()]
|
||||
isys.personal_data_category = cats
|
||||
if hasattr(self, 'is_pd_count_entry'):
|
||||
isys.pd_count = self.is_pd_count_entry.get().strip() or 'менее 100 000'
|
||||
# Списки
|
||||
isys.personal_data_list = list(self.pd_listbox.get(0, tk.END))
|
||||
isys.pd_subjects_list = list(self.pd_subjects_listbox.get(0, tk.END))
|
||||
isys.pd_actions_list = list(self.pd_actions_listbox.get(0, tk.END))
|
||||
isys.defense_tools_list = list(self.defense_tools_listbox.get(0, tk.END))
|
||||
isys.users_list = list(self.users_listbox.get(0, tk.END))
|
||||
|
||||
@@ -679,127 +534,9 @@ class DokoGenApp:
|
||||
self.is_listbox.selection_set(0)
|
||||
self._on_is_select()
|
||||
|
||||
# Пользователи
|
||||
if hasattr(self, '_employee_entries'):
|
||||
for row_frame, _, _, _ in self._employee_entries:
|
||||
row_frame.destroy()
|
||||
self._employee_entries.clear()
|
||||
for emp in c.employees_access:
|
||||
self._add_employee_row(
|
||||
emp.get('position', ''),
|
||||
emp.get('fio', ''),
|
||||
)
|
||||
self._update_emp_count()
|
||||
|
||||
# Шаблоны
|
||||
self._on_templates_changed()
|
||||
|
||||
# Автоопределение города из адреса после загрузки
|
||||
self._on_address_changed()
|
||||
|
||||
# ==================== ГОРОД ИЗ АДРЕСА ====================
|
||||
def _extract_city_from_address(self, address: str) -> str:
|
||||
"""Извлекает название города из адреса (простая эвристика)."""
|
||||
if not address:
|
||||
return ''
|
||||
# Пробуем найти "г. Город" или "Г Город" (без точки, заглавная)
|
||||
import re
|
||||
m = re.search(r'\b[гГ]\.?\s*([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
# Пробуем найти "город Город"
|
||||
m = re.search(r'\bгород\s+([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address, re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
# Пробуем первую часть до запятой, если там известный город
|
||||
parts = [p.strip() for p in address.split(',')]
|
||||
if parts:
|
||||
first = parts[0]
|
||||
# Если первая часть — короткая и не похожа на улицу/дом
|
||||
if len(first) <= 25 and first and not first.startswith(('ул', 'пр', 'пер', 'шоссе')):
|
||||
# Проверяем, что это похоже на город
|
||||
city_keywords = ['ск', 'цк', 'бург', 'град', 'полис', 'поль', 'стан', 'горск', 'чинск', 'янск', 'овск']
|
||||
if any(first.lower().endswith(kw) for kw in city_keywords):
|
||||
return first
|
||||
return ''
|
||||
|
||||
def _on_address_changed(self, event=None):
|
||||
"""Обработчик изменения адреса — автозаполняет город."""
|
||||
addr_entry = self._company_entries.get('address')
|
||||
city_entry = self._company_entries.get('city_name')
|
||||
if not addr_entry or not city_entry:
|
||||
return
|
||||
address = addr_entry.get().strip()
|
||||
if not address:
|
||||
return
|
||||
current_city = city_entry.get().strip()
|
||||
if current_city:
|
||||
return # Не перезаписываем, если уже заполнен
|
||||
city = self._extract_city_from_address(address)
|
||||
if city:
|
||||
city_entry.delete(0, tk.END)
|
||||
city_entry.insert(0, city)
|
||||
self.log(f"🏙 Город определён: {city}")
|
||||
|
||||
# ==================== АВТОРАСЧЁТ УРОВНЯ ЗАЩИЩЁННОСТИ ====================
|
||||
def _auto_calc_defence_level(self):
|
||||
"""Автоматический расчёт УЗ по ПП №1119: категории ПДн, тип субъектов,
|
||||
кол-во записей, тип угроз."""
|
||||
if self._current_is_idx is None:
|
||||
return
|
||||
|
||||
categories = [cat for cat, var in self.category_vars if var.get()]
|
||||
pd_count = self.is_pd_count_var.get() if hasattr(self, 'is_pd_count_var') else 'менее 100 000'
|
||||
threat_type = self.is_threat_var.get() if hasattr(self, 'is_threat_var') else '3'
|
||||
is_large = 'более' in pd_count.lower()
|
||||
# Автоопределение типа субъектов: если в Субъектах ПДн выбраны только "Сотрудники" → работники, иначе → иные
|
||||
subj_items = self.pd_subjects_listbox.get(0, tk.END) if hasattr(self, 'pd_subjects_listbox') else []
|
||||
has_only_employees = len(subj_items) > 0 and all('сотрудник' in s.lower() for s in subj_items)
|
||||
is_employees = has_only_employees or len(subj_items) == 0
|
||||
|
||||
# Таблица ПП №1119: (категория, тип_субъектов, объём, тип_угроз) → УЗ
|
||||
def lookup(cat_group, employees, large, threat):
|
||||
# Общедоступные → всегда 4
|
||||
if cat_group == 'public':
|
||||
return 4
|
||||
# Специальные/биометрические
|
||||
if cat_group == 'special_bio':
|
||||
tbl = {
|
||||
('emp', False, '1'): 2, ('emp', True, '1'): 1,
|
||||
('other', False, '1'): 1, ('other', True, '1'): 1,
|
||||
('emp', False, '2'): 2, ('emp', True, '2'): 1,
|
||||
('other', False, '2'): 2, ('other', True, '2'): 1,
|
||||
('emp', False, '3'): 3, ('emp', True, '3'): 2,
|
||||
('other', False, '3'): 3, ('other', True, '3'): 2,
|
||||
}
|
||||
else: # иные
|
||||
tbl = {
|
||||
('emp', False, '1'): 3, ('emp', True, '1'): 2,
|
||||
('other', False, '1'): 2, ('other', True, '1'): 1,
|
||||
('emp', False, '2'): 4, ('emp', True, '2'): 3,
|
||||
('other', False, '2'): 3, ('other', True, '2'): 2,
|
||||
('emp', False, '3'): 4, ('emp', True, '3'): 4,
|
||||
('other', False, '3'): 4, ('other', True, '3'): 3,
|
||||
}
|
||||
key = ('emp' if employees else 'other', large, threat)
|
||||
return tbl.get(key, 4)
|
||||
|
||||
# Определяем группу категорий
|
||||
if 'общедоступные' in categories and not any(c in categories for c in ['специальные', 'биометрические', 'иные']):
|
||||
cat_group = 'public'
|
||||
elif any(c in categories for c in ['специальные', 'биометрические']):
|
||||
cat_group = 'special_bio'
|
||||
else:
|
||||
cat_group = 'other'
|
||||
|
||||
level = lookup(cat_group, is_employees, is_large, threat_type)
|
||||
self.is_defence_var.set(str(level))
|
||||
|
||||
def _on_categories_or_count_changed(self):
|
||||
"""Обработчик изменения категорий ПДн или количества записей."""
|
||||
if self._current_is_idx is not None:
|
||||
self._auto_calc_defence_level()
|
||||
|
||||
# ==================== КОМИССИЯ ====================
|
||||
def _add_commission_row(self, role="Член комиссии", position="", fio=""):
|
||||
row = ttk.Frame(self._comm_members_frame)
|
||||
@@ -808,7 +545,7 @@ class DokoGenApp:
|
||||
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)
|
||||
@@ -845,6 +582,8 @@ class DokoGenApp:
|
||||
|
||||
def _set_is_edit_state(self, enabled):
|
||||
state = 'normal' if enabled else 'disabled'
|
||||
for w in [self.is_name_entry, self.is_description_entry, self.is_software_entry]:
|
||||
w.config(state=state)
|
||||
self.is_lan_var.set(False)
|
||||
self.is_internet_var.set(False)
|
||||
|
||||
@@ -867,22 +606,19 @@ class DokoGenApp:
|
||||
self.is_name_entry.insert(0, isys.name or "")
|
||||
self.is_description_entry.delete(0, tk.END)
|
||||
self.is_description_entry.insert(0, isys.description or "")
|
||||
self.is_software_entry.delete(0, tk.END)
|
||||
self.is_software_entry.insert(0, isys.software or "")
|
||||
self.is_lan_var.set(isys.is_local_network)
|
||||
self.is_internet_var.set(isys.is_internet)
|
||||
self.is_threat_var.set(isys.threat_type or "3")
|
||||
self.is_defence_var.set(isys.defence_level or "4")
|
||||
for cat, var in self.category_vars:
|
||||
var.set(cat in isys.personal_data_category)
|
||||
# Заполняем кол-во записей ПДн
|
||||
if hasattr(self, 'is_pd_count_entry'):
|
||||
self.is_pd_count_entry.delete(0, tk.END)
|
||||
self.is_pd_count_entry.insert(0, isys.pd_count or 'менее 100 000')
|
||||
|
||||
# Списки
|
||||
for lb, items in [
|
||||
(self.pd_listbox, isys.personal_data_list or []),
|
||||
(self.pd_subjects_listbox, isys.pd_subjects_list or []),
|
||||
(self.pd_actions_listbox, isys.pd_actions_list or []),
|
||||
(self.defense_tools_listbox, isys.defense_tools_list or []),
|
||||
(self.users_listbox, isys.users_list or []),
|
||||
]:
|
||||
@@ -891,8 +627,6 @@ class DokoGenApp:
|
||||
lb.insert(tk.END, item)
|
||||
|
||||
def _add_is(self):
|
||||
# Сохраняем текущую ИС перед добавлением новой
|
||||
self._collect_is()
|
||||
name = simpledialog.askstring("Добавить ИС", "Введите наименование ИС:")
|
||||
if name:
|
||||
isys = InformationSystem(name=name)
|
||||
@@ -909,14 +643,13 @@ class DokoGenApp:
|
||||
copy = InformationSystem(
|
||||
name=src.name + " (копия)",
|
||||
description=src.description,
|
||||
software=src.software,
|
||||
is_local_network=src.is_local_network,
|
||||
is_internet=src.is_internet,
|
||||
defence_level=src.defence_level,
|
||||
subject_type=src.subject_type,
|
||||
threat_type=src.threat_type,
|
||||
personal_data_category=list(src.personal_data_category),
|
||||
personal_data_list=list(src.personal_data_list),
|
||||
pd_actions_list=list(src.pd_actions_list),
|
||||
pd_subjects_list=list(src.pd_subjects_list),
|
||||
defense_tools_list=list(src.defense_tools_list),
|
||||
users_list=list(src.users_list),
|
||||
@@ -929,7 +662,6 @@ class DokoGenApp:
|
||||
def _delete_is(self):
|
||||
if self._current_is_idx is None:
|
||||
return
|
||||
self._collect_is()
|
||||
del self.model.information_systems[self._current_is_idx]
|
||||
self._current_is_idx = None
|
||||
self._refresh_is_listbox()
|
||||
@@ -962,118 +694,6 @@ class DokoGenApp:
|
||||
if sel:
|
||||
self.users_listbox.delete(sel[0])
|
||||
|
||||
def _del_from_listbox(self, listbox):
|
||||
"""Удаление выбранного элемента из любого listbox."""
|
||||
sel = listbox.curselection()
|
||||
if sel:
|
||||
listbox.delete(sel[0])
|
||||
|
||||
# ==================== ВЫБОР ИЗ СПИСКА (словари) ====================
|
||||
def _select_from_dialog(self, title: str, items: list, listbox):
|
||||
"""Открывает окно с чекбоксами для выбора элементов из списка."""
|
||||
dialog = tk.Toplevel(self.root)
|
||||
dialog.title(title)
|
||||
dialog.geometry("400x500")
|
||||
dialog.transient(self.root)
|
||||
dialog.grab_set()
|
||||
|
||||
frame = ttk.Frame(dialog, padding=10)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
ttk.Label(frame, text=title, font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=(0, 5))
|
||||
|
||||
# Уже выбранные элементы
|
||||
existing = set(listbox.get(0, tk.END))
|
||||
|
||||
canvas = tk.Canvas(frame, highlightthickness=0)
|
||||
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas.yview)
|
||||
check_frame = ttk.Frame(canvas)
|
||||
check_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||||
canvas.create_window((0, 0), window=check_frame, anchor="nw")
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
vars = {}
|
||||
for item in items:
|
||||
var = tk.BooleanVar(value=item in existing)
|
||||
cb = ttk.Checkbutton(check_frame, text=item, variable=var)
|
||||
cb.pack(anchor=tk.W, pady=1)
|
||||
vars[item] = var
|
||||
|
||||
btn_frame = ttk.Frame(dialog, padding=10)
|
||||
btn_frame.pack(fill=tk.X)
|
||||
|
||||
def _apply():
|
||||
selected = [item for item, var in vars.items() if var.get()]
|
||||
listbox.delete(0, tk.END)
|
||||
for item in selected:
|
||||
listbox.insert(tk.END, item)
|
||||
dialog.destroy()
|
||||
|
||||
def _select_all():
|
||||
for var in vars.values():
|
||||
var.set(True)
|
||||
|
||||
def _clear_all():
|
||||
for var in vars.values():
|
||||
var.set(False)
|
||||
|
||||
ttk.Button(btn_frame, text="Выбрать всё", command=_select_all).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(btn_frame, text="Снять всё", command=_clear_all).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(btn_frame, text="✅ Применить", command=_apply).pack(side=tk.RIGHT, padx=2)
|
||||
|
||||
dialog.wait_window()
|
||||
|
||||
def _add_pd_action_item(self):
|
||||
item = simpledialog.askstring("Добавить действие", "Введите действие с ПДн:")
|
||||
if item:
|
||||
self.pd_actions_listbox.insert(tk.END, item)
|
||||
|
||||
def _select_from_actions_list(self):
|
||||
"""Выбор действий с ПДн из JSON словаря."""
|
||||
import json
|
||||
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_actions.json')
|
||||
try:
|
||||
with open(dict_path, 'r', encoding='utf-8') as f:
|
||||
items = json.load(f)
|
||||
self._select_from_dialog("Выбор действий с персональными данными", items, self.pd_actions_listbox)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
|
||||
|
||||
def _select_from_pd_list(self):
|
||||
"""Выбор ПДн из JSON словаря."""
|
||||
import json
|
||||
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_items.json')
|
||||
try:
|
||||
with open(dict_path, 'r', encoding='utf-8') as f:
|
||||
items = json.load(f)
|
||||
self._select_from_dialog("Выбор персональных данных", items, self.pd_listbox)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
|
||||
|
||||
def _select_from_subjects_list(self):
|
||||
"""Выбор категорий субъектов из JSON словаря."""
|
||||
import json
|
||||
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_categories.json')
|
||||
try:
|
||||
with open(dict_path, 'r', encoding='utf-8') as f:
|
||||
items = json.load(f)
|
||||
self._select_from_dialog("Выбор категорий субъектов ПДн", items, self.pd_subjects_listbox)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
|
||||
|
||||
def _select_from_defense_list(self):
|
||||
"""Выбор СЗИ из JSON словаря."""
|
||||
import json
|
||||
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'defense_tools.json')
|
||||
try:
|
||||
with open(dict_path, 'r', encoding='utf-8') as f:
|
||||
items = json.load(f)
|
||||
self._select_from_dialog("Выбор средств защиты информации", items, self.defense_tools_listbox)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
|
||||
|
||||
# ==================== ШАБЛОНЫ ====================
|
||||
def select_templates(self):
|
||||
folder = filedialog.askdirectory(title="Папка шаблонов")
|
||||
@@ -1137,7 +757,6 @@ class DokoGenApp:
|
||||
self._collect_company()
|
||||
self._collect_commission()
|
||||
self._collect_is()
|
||||
self._collect_employees()
|
||||
|
||||
# Проверка
|
||||
if not self._validate_before_generate():
|
||||
@@ -1145,27 +764,6 @@ class DokoGenApp:
|
||||
|
||||
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:
|
||||
@@ -1194,14 +792,12 @@ class DokoGenApp:
|
||||
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()
|
||||
@@ -1272,135 +868,22 @@ class DokoGenApp:
|
||||
|
||||
def _apply_imported_data(self, data: dict):
|
||||
def _apply():
|
||||
# Название организации
|
||||
if data.get('fullName'):
|
||||
self.company_name_entry.delete(0, tk.END)
|
||||
self.company_name_entry.insert(0, data['fullName'])
|
||||
self.model.full_name = data['fullName']
|
||||
if data.get('shortName'):
|
||||
self.short_name_entry.delete(0, tk.END)
|
||||
self.short_name_entry.insert(0, data['shortName'])
|
||||
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]
|
||||
for key, entry in self._company_entries.items():
|
||||
val = data.get(key) or data.get(key.replace('_', '')) or ''
|
||||
if val:
|
||||
entry.delete(0, tk.END)
|
||||
entry.insert(0, val)
|
||||
|
||||
# Автоопределение города из адреса после импорта
|
||||
self._on_address_changed()
|
||||
|
||||
# Должностные лица (маппинг)
|
||||
officials_map = {
|
||||
'chief_position': 'chiefPosition',
|
||||
'chief_fio': 'chiefFio',
|
||||
'responsible_position': 'responsiblePosition',
|
||||
'responsible_fio': 'responsibleFio',
|
||||
'admin_position': ('administratorPosition', 'ispdnPosition'),
|
||||
'admin_fio': ('administratorFio', 'ispdnFio'),
|
||||
}
|
||||
for entry_key, data_keys in officials_map.items():
|
||||
entry = self._official_entries.get(entry_key)
|
||||
if not entry:
|
||||
continue
|
||||
val = ''
|
||||
if isinstance(data_keys, tuple):
|
||||
for dk in data_keys:
|
||||
if data.get(dk):
|
||||
val = data[dk]
|
||||
break
|
||||
elif data.get(data_keys):
|
||||
val = data[data_keys]
|
||||
for attr, entry in self._official_entries.items():
|
||||
val = data.get(attr) or ''
|
||||
if val:
|
||||
entry.delete(0, tk.END)
|
||||
entry.insert(0, val)
|
||||
|
||||
# Номер и дата документа
|
||||
if data.get('contractNumber'):
|
||||
self.doc_number_var.set(data['contractNumber'])
|
||||
if data.get('contractDate'):
|
||||
self.doc_date_var.set(data['contractDate'])
|
||||
|
||||
# Информационные системы
|
||||
if data.get('informationSystems'):
|
||||
self.model.information_systems.clear()
|
||||
for is_data in data['informationSystems']:
|
||||
isys = InformationSystem(
|
||||
name=is_data.get('name', ''),
|
||||
description=is_data.get('description', ''),
|
||||
is_internet=is_data.get('isInternet', False),
|
||||
pd_count=is_data.get('personalDataCount', 'менее 100 000'),
|
||||
personal_data_list=is_data.get('personalDataList', []),
|
||||
pd_subjects_list=is_data.get('pd_subjects_list', []),
|
||||
defense_tools_list=is_data.get('defense_tools', []),
|
||||
users_list=is_data.get('usersList', []),
|
||||
personal_data_category=is_data.get('personalDataCategory', []),
|
||||
processing_modes=is_data.get('processing_mode', ''),
|
||||
purpose=is_data.get('description', ''),
|
||||
room=is_data.get('room', ''),
|
||||
)
|
||||
self.model.information_systems.append(isys)
|
||||
self._refresh_is_listbox()
|
||||
if self.model.information_systems:
|
||||
self.is_listbox.selection_set(0)
|
||||
self._on_is_select()
|
||||
|
||||
# Комиссия
|
||||
if data.get('commission'):
|
||||
self.model.commission.clear()
|
||||
# Очистим UI
|
||||
for row_frame, _, _, _ in self.commission_entries:
|
||||
row_frame.destroy()
|
||||
self.commission_entries.clear()
|
||||
for m in data['commission']:
|
||||
self.model.commission.append(
|
||||
CommissionMember(
|
||||
role=m.get('role', 'Член комиссии'),
|
||||
position=m.get('position', ''),
|
||||
fio=m.get('fio', ''),
|
||||
)
|
||||
)
|
||||
self._add_commission_row(
|
||||
m.get('role', 'Член комиссии'),
|
||||
m.get('position', ''),
|
||||
m.get('fio', ''),
|
||||
)
|
||||
|
||||
# Сотрудники с доступом
|
||||
if data.get('employeesAccess'):
|
||||
self.model.employees_access.clear()
|
||||
if hasattr(self, '_employee_entries'):
|
||||
for row_frame, _, _, _ in self._employee_entries:
|
||||
row_frame.destroy()
|
||||
self._employee_entries.clear()
|
||||
for emp in data['employeesAccess']:
|
||||
self.model.employees_access.append({
|
||||
'position': emp.get('position', ''),
|
||||
'fio': emp.get('fio', ''),
|
||||
})
|
||||
self._add_employee_row(
|
||||
emp.get('position', ''),
|
||||
emp.get('fio', ''),
|
||||
)
|
||||
self._update_emp_count()
|
||||
|
||||
self.log("✅ Импорт применён")
|
||||
self.root.after(0, _apply)
|
||||
|
||||
@@ -1444,7 +927,6 @@ class DokoGenApp:
|
||||
self._collect_company()
|
||||
self._collect_commission()
|
||||
self._collect_is()
|
||||
self._collect_employees()
|
||||
try:
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2)
|
||||
@@ -1469,7 +951,6 @@ class DokoGenApp:
|
||||
self._collect_company()
|
||||
self._collect_commission()
|
||||
self._collect_is()
|
||||
self._collect_employees()
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self._autosave_path), exist_ok=True)
|
||||
with open(self._autosave_path, 'w', encoding='utf-8') as f:
|
||||
@@ -62,28 +62,6 @@ VARIABLE_DEFS = [
|
||||
|
||||
# === 152-ФЗ ===
|
||||
('{{is_security_requirements}}', 'B', 'Требования уровня защищённости', 'security_reqs'),
|
||||
('{{is_list}}', 'B', 'Список всех ИС (нумерованный)', 'is_list'),
|
||||
('{{is_name}}', 'B', 'Наименование первой ИС', 'is:name'),
|
||||
('{{name}}', 'B', 'Название ИС (алиас)', 'is:name'),
|
||||
('{{description}}', 'B', 'Описание ИС (алиас)', 'is:description'),
|
||||
('{{category}}', 'B', 'Категория ИС (алиас)', 'is:category'),
|
||||
('{{number}}', 'B', 'Номер (алиас)', 'doc_number'),
|
||||
('{{pd_count}}', 'B', 'Количество записей ПДн (алиас)', 'is:pd_count'),
|
||||
('{{pd_list}}', 'B', 'Список ПДн (алиас)', 'is:pd_list'),
|
||||
('{{pd_subjects}}', 'B', 'Субъекты ПДн (алиас)', 'is:pd_subjects'),
|
||||
('{{threat_type}}', 'B', 'Тип угроз (алиас)', 'is:threat_type'),
|
||||
('{{users}}', 'B', 'Пользователи ИС (алиас)', 'is:users'),
|
||||
('{{defence_level}}', 'B', 'Уровень защищённости (алиас)', 'is:defence_level'),
|
||||
('{{full_name_genitive}}', 'A', 'Полное наименование (род.п., алиас)', 'decl:full_name:gent'),
|
||||
('{{document}}', 'A', 'Название бумажного документа (алиас)', 'company:paper_documents'),
|
||||
('{{storage}}', 'A', 'Место хранения (алиас)', 'company:paper_storage'),
|
||||
('{{responsible_fio_accs}}', 'A', 'ФИО ответственного (вин.п.)', 'fio_acl:responsible'),
|
||||
('{{commission1}}', 'A', 'Должность члена комиссии 1', 'commission_member:0:position'),
|
||||
('{{commission1_fio}}', 'A', 'ФИО члена комиссии 1', 'commission_member:0:fio'),
|
||||
('{{commission2}}', 'A', 'Должность члена комиссии 2', 'commission_member:1:position'),
|
||||
('{{commission2_fio}}', 'A', 'ФИО члена комиссии 2', 'commission_member:1:fio'),
|
||||
('{{commission3}}', 'A', 'Должность члена комиссии 3', 'commission_member:2:position'),
|
||||
('{{commission3_fio}}', 'A', 'ФИО члена комиссии 3', 'commission_member:2:fio'),
|
||||
]
|
||||
|
||||
# Маппинг старых переменных <...> → {{...}}
|
||||
@@ -191,6 +169,7 @@ def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int):
|
||||
'is_users': ', '.join(isys.users_list or []) or '—',
|
||||
'is_pd_count': str(isys.pd_count or '—'),
|
||||
'is_purpose': isys.purpose or '—',
|
||||
'is_software_list': isys.software or '—',
|
||||
}
|
||||
for f, v in fields.items():
|
||||
replacements[f'{{{{{f}_{idx}}}}}'] = str(v)
|
||||
@@ -219,6 +198,7 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
|
||||
items.append({
|
||||
'name': isys.name or '—',
|
||||
'description': isys.description or '—',
|
||||
'software': isys.software or '—',
|
||||
'is_has_lan': 'Да' if isys.is_local_network else 'Нет',
|
||||
'has_internet': 'да' if isys.is_internet else 'нет',
|
||||
'defence_level': str(isys.defence_level or '—'),
|
||||
@@ -313,54 +293,6 @@ def _resolve(source: str, ctx: Dict) -> str:
|
||||
if source == 'security_reqs':
|
||||
return _get_security_text(ctx)
|
||||
|
||||
# IS-алиасы (первая ИС)
|
||||
if source.startswith('is:'):
|
||||
field = source[3:]
|
||||
if is_list:
|
||||
first = is_list[0]
|
||||
field_map = {
|
||||
'name': 'name',
|
||||
'description': 'description',
|
||||
'category': 'category',
|
||||
'defence_level': 'defence_level',
|
||||
'threat_type': 'threat_type',
|
||||
'pd_list': lambda: ', '.join(first.personal_data_list or []),
|
||||
'pd_count': 'pd_count',
|
||||
'pd_subjects': lambda: '; '.join(first.pd_subjects_list or []),
|
||||
'users': lambda: ', '.join(first.users_list or []),
|
||||
}
|
||||
if field in field_map:
|
||||
val = field_map[field]
|
||||
if callable(val):
|
||||
return val() or '—'
|
||||
return str(getattr(first, val, '') or '—') if isinstance(val, str) else '—'
|
||||
return '—'
|
||||
|
||||
# Список ИС
|
||||
if source == 'is_list':
|
||||
if is_list:
|
||||
return '\n'.join(f"{i}. {isys.name}" for i, isys in enumerate(is_list, 1))
|
||||
return '—'
|
||||
if source == 'is_list_comma':
|
||||
if is_list:
|
||||
return ', '.join(isys.name for isys in is_list)
|
||||
return '—'
|
||||
|
||||
# Члены комиссии
|
||||
if source.startswith('commission_member:'):
|
||||
parts = source.split(':')
|
||||
idx, field = int(parts[1]), parts[2]
|
||||
members = getattr(company, 'commission', None) or []
|
||||
if idx < len(members):
|
||||
return str(getattr(members[idx], field, '') or '—')
|
||||
return '—'
|
||||
|
||||
# Склонение ФИО по падежам
|
||||
if source.startswith('fio_acl:'):
|
||||
who = source.split(':', 1)[1]
|
||||
fio = _get_fio(company, who)
|
||||
return dc.decline(fio, 'accs') if fio else '—'
|
||||
|
||||
return '—'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user