1 Commits
Author SHA1 Message Date
prog1764 72bbb117b9 🎉 Первый коммит — DokoGen v1.0
Чистый генератор документов 152-ФЗ.
Только нужные переменные, без мусора от 187-ФЗ/117-ФСТЭК.
Модели, склонение, генератор DOCX, tkinter GUI, импорт из Excel.
2026-07-30 13:47:02 +04:00
15 changed files with 413 additions and 1756 deletions
-3
View File
@@ -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__ = "ДоКоГеНеРаТоР"
View File
-14
View File
@@ -1,14 +0,0 @@
[
"Антивирусное ПО",
"СКЗИ (криптография)",
"Межсетевой экран",
"СЗИ от НСД",
"СОВ (обнаружение вторжений)",
"Доверенная загрузка",
"SIEM-система",
"DLP-система",
"VPN",
"Система парольной защиты",
"Антиспам",
"Средства резервного копирования"
]
-22
View File
@@ -1,22 +0,0 @@
[
"Сбор персональных данных",
"Запись персональных данных",
"Систематизация персональных данных",
"Накопление персональных данных",
"Хранение персональных данных",
"Уточнение (обновление, изменение) персональных данных",
"Извлечение персональных данных",
"Использование персональных данных",
"Передача (распространение, предоставление, доступ) персональных данных",
"Обезличивание персональных данных",
"Блокирование персональных данных",
"Удаление персональных данных",
"Уничтожение персональных данных",
"Трансграничная передача персональных данных",
"Автоматизированная обработка персональных данных",
"Неавтоматизированная обработка персональных данных",
"Смешанная обработка персональных данных",
"Формирование и ведение баз данных",
"Передача данных третьим лицам",
"Внутренняя передача данных между подразделениями"
]
-8
View File
@@ -1,8 +0,0 @@
[
"работники оператора",
"клиенты",
"контрагенты",
"родственники работников",
"соискатели",
"иные лица"
]
-28
View File
@@ -1,28 +0,0 @@
[
"Фамилия, имя, отчество",
"Дата рождения",
"Место рождения",
"Паспортные данные",
"Адрес регистрации",
"Адрес проживания",
"ИНН",
"СНИЛС",
"Номер телефона",
"Адрес электронной почты",
"Образование",
"Профессия",
"Сведения о доходах",
"Семейное положение",
"Состав семьи",
"Состояние здоровья",
"Национальность",
"Гражданство",
"Сведения о судимости",
"Фотография",
"Биометрические данные",
"Рабочий e-mail",
"Должность",
"Табельный номер",
"Сведения о воинском учете",
"Сведения о командировках"
]
-430
View File
@@ -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'(?:&lt;!--|<!--)\s*loop:(\w+)\s*(?:--&gt;|-->)')
LOOP_END = re.compile(r'(?:&lt;!--|<!--)\s*loop_end\s*(?:--&gt;|-->)')
# Маппинг старых переменных <...> → {{...}}
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
-430
View File
@@ -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
View File
@@ -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'(?:&lt;!--|<!--)?\s*loop:(\w+)(?:\s*(?:--&gt;|-->))?')
LOOP_END = re.compile(r'(?:&lt;!--|<!--)?\s*loop_end(?:\s*(?:--&gt;|-->))?')
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)
+5 -1
View File
@@ -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__":
+1 -5
View File
@@ -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})
-199
View File
@@ -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
View File
@@ -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:
+2 -70
View File
@@ -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 ''