56 Commits
Author SHA1 Message Date
prog1764 9d57dbe0e5 Удалён архив После генерации.rar (перенесён на сервер) 2026-08-03 22:55:04 +04:00
prog1764 12f94d0927 Удалён архив Шаблоны.rar (перенесён на сервер) 2026-08-03 22:55:02 +04:00
kneon223557 d68eef4bbe Загрузить файлы в «/» 2026-08-03 22:45:36 +04:00
prog1764 6ccea72dff fix: _set_app_icon разрывал __init__ (NameError: root is not defined) 2026-08-03 22:29:54 +04:00
prog1764 fb01a14c37 feat: иконка на окне программы (заголовок/панель задач) + в ресурсах exe 2026-08-03 22:02:32 +04:00
prog1764 5efc3a0e99 feat: иконка — 64/128px из нового оригинала (512x512) 2026-08-03 21:59:26 +04:00
prog1764 771bc6987f feat: иконка — 48px из нового оригинала (512) 2026-08-03 21:58:11 +04:00
prog1764 faf675e861 feat: иконка — 48px из отдельного оригинала (все размеры из своих файлов) 2026-08-03 21:56:50 +04:00
prog1764 7ebcf04f63 feat: иконка — каждый размер из отдельного оригинала (16/24/32/48/64/128/256) 2026-08-03 21:56:02 +04:00
prog1764 3134d19089 feat: иконка — все размеры из оригинальных рисунков (16px отдельный, 24-32 из малого, 48-256 из большого) 2026-08-03 21:55:11 +04:00
prog1764 dff3f7b971 feat: иконка — комбинированный ICO (мелкие 16-32 из малого рисунка, крупные 48-256 из большого) 2026-08-03 21:53:56 +04:00
prog1764 307f632363 feat: иконка 1024x1024 (ICO 16-256px) для exe 2026-08-03 21:52:29 +04:00
prog1764 9d156bfc0d feat: иконка приложения (dokogen/icon.ico) подключена к сборке 2026-08-03 21:49:12 +04:00
prog1764 5d7d727ef9 fix: build_exe.bat переписан в ASCII (кириллица в UTF-8 ломала cmd) 2026-08-03 21:30:56 +04:00
prog1764 2c3d25e790 feat: сборка в exe (PyInstaller)
- dokogen.spec: упаковка dictionaries/, data/, словарей pymorphy3,
  скрытые импорты lxml/openpyxl; один файл dist/DokoGen.exe
- build_exe.bat: установка PyInstaller + зависимостей + сборка
2026-08-03 20:35:17 +04:00
prog1764 5c75a72b51 fix: ttk.Checkbutton не принимает опцию font (TclError unknown option) 2026-08-03 20:27:59 +04:00
prog1764 7828684b7d fix: жёлтая подсветка пустых значений в циклах + выборочный Применить
- Циклы (item.*, user_*, алиасы): пустые значения '—'/'' теперь
  подсвечиваются жёлтым маркером (раньше подстановка шла на уровне
  XML простым replace — подсветка терялась)
- Вложенные циклы (users_loop): та же подсветка
- Панель «Проверка данных»: чекбоксы «Подставлять: склонения /
  кр. склон. / адрес / ИНН-КПП-ОГРН / руковод.» — выборочный Применить
- get_short_fio/get_initials: не теряют отчество в сокращённом ФИО
2026-08-03 19:53:08 +04:00
prog1764 1498e56579 fix: инициалы ФИО не теряли отчество
- get_short_fio/get_initials: 'Черник В.Н.' превращалось в 'Черник В.'
  (терялся инициал отчества) — добавлена функция _initial, которая
  сохраняет уже сокращённые инициалы ('В.Н.') как есть
2026-08-03 17:40:04 +04:00
prog1764 a16f5740cb fix: генератор берёт склонения из модели (интерфейса), а не пересчитывает
- {{company_full_name_genitive}} и др. теперь сначала берут готовое
  склонение из company_name_N / short_name_N (то, что пользователь
  подставил через Морфер/ИИ или ввёл вручную)
- пересчёт через pymorphy — только если поле пустое
- раньше документ всегда пересчитывал заново → расхождение с
  интерфейсом
2026-08-03 17:34:04 +04:00
prog1764 eb8801d78e fix: DaData — индекс подставляется в адрес (postal_code)
- DaData отдаёт индекс отдельным полем postal_code, а в адресе его
  не было — теперь добавляем индекс в начало адреса, если его там нет
- если индекс уже есть — не дублируем
2026-08-03 17:20:47 +04:00
prog1764 fd6fd60507 fix: именительный падеж при веб-склонении (Морфер/ИИ)
- Морфер и ИИ не всегда возвращают именительный падеж (он равен
  исходному слову) — теперь подставляем его сами
- табло результатов склонения показывает ВСЕ 6 падежей, включая
  именительный (раньше показывал только Р,Д,В,Т,П)
2026-08-03 16:59:28 +04:00
prog1764 17a99627bc fix: табы больше не съедаются при обработке циклов
- _normalize_loop_markers при склейке run'ов выбрасывал <w:tab/>
- теперь табы превращаются в символ \t при сборе и восстанавливаются
  как <w:tab/> при пересборке параграфа
2026-08-03 16:16:22 +04:00
prog1764 d02e2ac9a0 fix: дубль роли в подписи комиссии
- {{item.signature}} больше НЕ содержит роль (роль выводится
  отдельной колонкой {{item.role}}) — иначе «Председатель комиссии |
  Председатель комиссии: ___»
- signature = «___________ / » (только подчёркивание и слэш)
- signature_full по-прежнему полная: «Роль: ___________ / И.О. Фамилия»
2026-08-03 16:12:48 +04:00
prog1764 26b51b53ce fix: незаменённые переменные снова подсвечиваются красным
- при пересборке параграфа незаменённая переменная оставалась
  в тексте (раньше выбрасывалась) → красный маркер снова работает
2026-08-03 16:08:59 +04:00
prog1764 0929f2c551 fix: добавлен import sys в ui.py (NameError при запуске) 2026-08-03 15:58:35 +04:00
prog1764 7c88351252 feat: вложенный цикл users_loop — все пользователи ИС включая администратора
- <!-- loop:users_loop --> внутри цикла ИС: {{user_fio}}, {{user_position}}
- администратор НЕ исключается (как просил Костя), убираются только
  дубли по ФИО (нормализованное сравнение)
2026-08-03 15:20:11 +04:00
prog1764 208e449953 feat: сравнение полученных значений с введёнными
- после проверки DaData/ИИ программа сравнивает результат с тем,
  что заполнено в программе
- серьёзные расхождения выводятся КРАСНЫМИ БОЛЬШИМИ буквами:
  «ВНИМАНИЕ! НАЙДЕНЫ РАСХОЖДЕНИЯ: Руководитель (ФИО): было
  «Иванов И.И.», стало «Петров П.П.»»
- регистр/пунктуация (ИВАНОВ.И.И. vs Иванов И.И.) НЕ считаются
  расхождением; ИНН/КПП/ОГРН — любое отличие цифр = ошибка
- расхождения также пишутся в лог
2026-08-03 14:35:28 +04:00
prog1764 50d3afdbf2 feat: логи, фикс табов, справка с инструкциями
1) Логи: все действия пишутся в dokogen.log (импорт, генерация,
   обращения к Морфер/DaData/ИИ с ответами), кнопка «Экспорт логов»
   на вкладке «Лог»
2) Табы: при пересборке параграфа табуляция сохраняется как <w:tab/>
   (раньше Word «съедал» табы между «_____» и датой)
3) Справка: инструкция пользователя (циклы, программа), инструкция
   администратора (словари, API-ключи, лимиты), справочник переменных,
   пустой опросник Excel — всё скачивается в Word/Excel
2026-08-03 14:32:59 +04:00
prog1764 01d317eab1 feat: импорт «Описание ИС» из опросника
- новая строка 8 «Описание ИС» на листах ИС → {{item.description}}
- значение кладётся напрямую в description (без префикса «Описание ИС:»)
2026-08-03 14:07:01 +04:00
prog1764 e7b1af3f05 fix: {{item.signature}} — рамка без ФИО (дубль с {{item.fio_short}})
- раньше signature содержала ФИО внутри: при шаблоне
  {{item.signature}}{{item.fio_short}} получалось «Черник В.Н.Черник В.Н.»
- теперь: signature = «Роль: ___________ / » (только рамка),
  signature_full = полная подпись «Роль: ___________ / И.О. Фамилия»
  для тех, кто использует одну переменную
2026-08-03 14:03:39 +04:00
prog1764 fc374a4974 fix: категории ПДн — {{category}} берётся из галочек
- раньше переменная читала отдельное поле category (пустое при
  работе с галочками) → в документ вставлялось «—»
- теперь: ', '.join(personal_data_category) — галочки «иные»,
  «специальные» и т.д. попадают в документ
2026-08-03 13:59:02 +04:00
prog1764 0b540f093b fix: проверка информации — 4 замечания
1) ИИ универсален: вместо DeepSeek — «ИИ» (OpenAI-совместимый:
   DeepSeek, ChatGPT и др.). В настройках: API-ключ, модель, базовый URL
2) склонение проверяет И полное, И краткое название (Морфер и ИИ)
3) ИИ-проверка реквизитов показывает снимок полей (статус каждого),
   замечания и предложения — а не просто «ОК»
4) DaData: регистр нормализуется (КАПС → обычный), адрес (г., ул., д.),
   дата ОГРН из миллисекунд → 09.02.2005
- совместимость со старым api_settings.json (deepseek_* → ai_*)
2026-08-03 13:29:09 +04:00
prog1764 9d2690e3e0 fix: 6 замечаний по генератору
1) нормализация адреса: «Р-Н Крымский» → «р-н. Крымский»,
   «Г Крымск» → «г. Крымск», «УЛ» → «ул.», «Д.» → «д.»
2) убрана строчка «ПО» на вкладке ИС
3) СЗИ без категорий: «Kaspersky, КриптоПро» (через запятую)
4) кнопка «Выбрать из списка» в подвкладке «Пользователи ИС»
5) бумажные документы: «хранятся в сейфе» (авто-предлог+падеж)
6) незаполненные данные «—» подсвечиваются жёлтым (незаменённые
   переменные — красным, как было)
2026-08-03 13:23:52 +04:00
prog1764 2f4a08b2e4 feat: проверка данных через Морфер/DaData/DeepSeek (ИИ)
- правая панель на вкладке «Организация»: проверка склонений
  (Морфер 3.0 — бесплатно, без ключа; DeepSeek — ИИ) и реквизитов
  (DaData по ИНН; DeepSeek — ИИ-рецензия)
- мини-табло результата: источник, дисклеймер для ИИ («сгенерировано
  ИИ — перепроверьте»), кнопки «Применить»/«Очистить»
- меню «Файл → Настройки…»: вкладка API-ключей (DaData, DeepSeek,
  модель), сохраняются в api_settings.json рядом с программой
- веб-проверки показывают источник (morpher.ru / dadata.ru)
2026-08-03 12:59:51 +04:00
prog1764 3a4cd2fde4 fix: нормализация КАПСа по словам (город + должности)
- раньше: если в тексте есть аббревиатура (ООО, И.О.) или цифры
  (индекс в адресе) — весь текст оставался КАПСОМ
- теперь: каждое слово нормализуется отдельно, аббревиатуры
  (АРМ, СКУД, ООО, ИНН и т.п.) и инициалы сохраняются
- город: '350000, Г. КРАСНОДАР...' → 'Г. Краснодар' → город
  снова определяется при импорте
- должность: 'ГЛАВА' → 'Глава', ФИО: 'ИВАНОВ ИВАН ИВАНОВИЧ' → 'Иванов Иван Иванович'
2026-08-03 12:45:10 +04:00
prog1764 4fca29e7d6 fix: структура ИС — чтение и из колонки B, и из той же ячейки после ':'
- если значение записано в B7 — берём оттуда
- если в A7 «Структура ИС: АРМ 2 шт.» — извлекаем после двоеточия
2026-08-03 12:39:42 +04:00
prog1764 12817588ff fix: ЛВС не импортировалась в модель ИС (галочка терялась)
- is_local_network=is_data.get('is_has_lan') при создании ИС из импорта
- проверено: АРМ №1 → ЛВС=да, Интернет=да
2026-08-03 12:33:02 +04:00
prog1764 1faf6196af feat: вкладка «Бумажные документы» + импорт из опросника
- Новая вкладка: название документа + место хранения, добавить/удалить
- Импорт из опросника: «Названия документов» (строка 61) и «Место
  хранения» (строка 62) → цикл {{paper_documents}} ({{document}}/{{storage}})
- Сохранение/автосохранение/генерация собирают данные вкладки
- Загрузка файла проекта восстанавливает список документов
2026-08-03 12:27:32 +04:00
prog1764 353e7403d0 fix: импорт под новый формат опросника 5.25
- clean_value не портит аббревиатуры/номера (АРМ №1, СКУД)
- ПО: 'Названия программ...' → {{item.software}}
- 'локальную сеть' → is_has_lan (поле is_local_network)
- Цель обработки ПД — значение из объединённой ячейки (val_b)
- Роли комиссии ищутся и в колонке E, и в Примечании (D)
- Проверено на присланном опроснике: КПП, подразделение по
  безопасности, действия с ПДн, структура ИС — импортируются
2026-08-03 12:22:49 +04:00
prog1764 79952bf735 feat: настройки импорта вынесены в текстовый файл-словарь
- import_settings.json рядом с программой (создаётся автоматически):
  что импортировать (var), откуда (cell: 'Общие сведения'!B2 или
  keywords: регэксп поиска по колонке A)
- importer.py читает словарь: сначала конкретная ячейка, потом ключевые слова
- листы общих сведений/сотрудников/ИС тоже настраиваются (ключевые слова)
- UI: меню «Генерация → Файл настроек импорта…» — выбор пути к словарю
- при сборке .exe файл остаётся редактируемым без пересборки
2026-08-03 12:09:13 +04:00
prog1764 9998410dd6 feat: импорт новых переменных из опросника 152-ФЗ
- {{security_department}} — наименование структурного подразделения
  по безопасности (из поля 'структурное подразделение')
- {{item.pd_actions}} — действия с ПДн в цикле ИС
- {{item.structure}} — структура ИС импортируется из листов ИС
- КПП уже импортировался ({{company_kpp}})
- UI: применение подразделения, структуры и действий при импорте
2026-08-03 12:00:57 +04:00
prog1764 ee76fabdf6 feat: поддержка синтаксиса {{item_поле}} (без точки) в циклах
Работают оба варианта: {{item.fio_initials}} и {{item_fio_initials}},
{{item.fio_short}} и {{item_fio_short}}.
Проверено: И.О. Фамилия -> В.Н. Черник, Фамилия И.О. -> Черник В.Н.
2026-08-03 11:20:20 +04:00
prog1764 c5f03164c8 feat: недостающие переменные из инструкции 152-ФЗ
- {{item.fio_initials}} (И.О. Фамилия) и {{item.fio_short}} (Фамилия И.О.)
  для комиссии
- {{item.software}} (ПО) для ИС + поле «ПО» в интерфейсе
- модель: поле software у InformationSystem
2026-08-03 11:17:20 +04:00
prog1764 4b4fcbc59d fix: убраны пустые строки между итерациями цикла + сохранены разрывы страниц
Word оставляет пустые параграфы и обрывки вокруг маркеров <!-- loop:... -->.
При размножении цикла они копировались между строками (комиссия слипалась
с пустыми строками). Теперь:
- template разделяется на prefix/core/suffix, пустые параграфы в core чистятся
- остатки маркеров удаляются после обработки (не трогая таблицы и page breaks)
- разрыв страницы из параграфа loop_end выносится между итерациями

Проверено: комиссия без пустых строк, акт 6 с 2 ИС — 2 разрыва страниц, XML валиден.
2026-08-03 11:14:10 +04:00
prog1764 24956cd27c feat: переменная подписи {{item.signature}} в цикле комиссии
Председатель комиссии: ___________ / А.А. Емельянов
Члены комиссии: _________________ / К.Е. Кузина
_________________ / Т.Б. Ахаева
2026-08-03 10:58:15 +04:00
prog1764 47630dbbfb feat: переменная «Структура ИС» (structure) в цикле information_systems
- models.py: поле structure у InformationSystem
- variables.py: {{item.structure}} / {{structure}} в цикле,
  {{is_structure_N}} в индексированных
- ui.py: поле «Структура ИС» на вкладке ИС
2026-08-03 10:56:35 +04:00
prog1764 577bed22d6 feat: клавиши (Ctrl/Alt/Del) в исключения + подсветка маркером только переменной
- IGNORED_OLD_VARS: <Ctrl>, <Alt>, <Del> и др. клавиши больше не
  считаются незаменёнными переменными (7.6 Инструкция Пользователя)
- Подсветка: вместо красного цвета текста — красный маркер
  (w:highlight) на самой переменной, а не на всём абзаце
- OLD_VAR_MAP: ShortCompanyName1-6 (склонения краткого названия),
  IspdnName → {{is_name_1}}
2026-08-03 10:53:59 +04:00
prog1764 f5e9d3523d feat: группировка комиссии — председатель отдельно, члены без повтора роли
- Председатель/Секретарь/Заместитель — своей ролью
- Первый обычный член получает «Члены комиссии», остальные — пустая роль
- Пустая строка в цикле больше не превращается в «—»
Проверено: 1 председатель + 4 члена в шаблоне «6. Акт...»
2026-08-03 10:34:05 +04:00
prog1764 65480ea5ed fix: сохранять разрыв страницы (w:br type=page) при развороте циклов
Word хранит перенос страницы отдельным run в параграфе с <!-- loop_end -->.
Нормализация раньше удаляла его при склейке текста — акты слипались.
Теперь page break сохраняется и копируется в каждую итерацию цикла.
Проверено: 2 ИС -> 2 разрыва страниц, XML валиден.
2026-08-03 10:29:10 +04:00
prog1764 f4306f130e fix: заменять переменные без префикса item. внутри циклов
Word разбивает на run'ы не только маркеры циклов, но и переменные
({{name}}, {{number}}, {{pd_list}} и т.д.). Нормализация теперь
склеивает все параграфы с {{...}}, а размножение цикла подставляет
и {{item.field}}, и {{field}} (шаблон '0. Акт обследования.docx')
2026-08-03 10:12:09 +04:00
prog1764 ab96991a72 fix: Word разбивает маркеры циклов/переменные на run'ы + адрес ИС
- _normalize_loop_markers: склейка текста параграфа в один run перед
  поиском <!-- loop:... --> и {{item.*}}, иначе циклы не разворачивались
  (шаблон '6. Акт определения уровня защищенности ПДн.docx')
- variables.py: поле address в цикле information_systems
2026-08-03 10:08:45 +04:00
prog1764 28ac7f69ab fix: пустой временный файл при отсутствии циклов в шаблоне (Package not found)
expand_loops_in_zip возвращал путь к пустому tmp-файлу, если в шаблоне
не было маркеров <!-- loop:... -->. python-docx падал с
'Package not found'. Теперь при отсутствии циклов возвращается
исходный шаблон, tmp удаляется. Тесты: без циклов, с циклом,
вложенные циклы — всё ок.
2026-08-03 09:28:45 +04:00
prog1764 12df5f106c Revert "feat: import_mapper + кнопка в UI"
This reverts commit dd909792d4.
2026-08-03 09:15:28 +04:00
prog1764 dd909792d4 feat: import_mapper + кнопка в UI 2026-07-31 00:39:07 +04:00
prog1764 44b4060021 fix: unpack 4→3 в _employee_entries (ui.py) 2026-07-31 00:36:57 +04:00
prog1764 66753f6607 merge: рабочий генератор из rewrite + тест пройден 2026-07-31 00:35:52 +04:00
29 changed files with 5472 additions and 1455 deletions
+43
View File
@@ -0,0 +1,43 @@
@echo off
chcp 65001 >nul
title Build DokoGen.exe
echo ============================================
echo Building DokoGen into a single EXE file
echo ============================================
echo.
cd /d "%~dp0"
echo [1/3] Installing PyInstaller...
py -m pip install --upgrade pyinstaller
if errorlevel 1 (
echo ERROR: failed to install PyInstaller
pause
exit /b 1
)
echo.
echo [2/3] Installing dependencies...
py -m pip install -r requirements.txt
if errorlevel 1 (
echo ERROR: failed to install dependencies
pause
exit /b 1
)
echo.
echo [3/3] Building DokoGen.exe (1-3 minutes)...
py -m PyInstaller --clean --noconfirm dokogen.spec
if errorlevel 1 (
echo ERROR: build failed
pause
exit /b 1
)
echo.
echo ============================================
echo DONE! File: dist\DokoGen.exe
echo Copy it to any Windows PC and run it.
echo Python is NOT required on target PC.
echo ============================================
pause
+63
View File
@@ -0,0 +1,63 @@
# -*- mode: python ; coding: utf-8 -*-
"""DokoGen spec для PyInstaller.
Сборка: py -m PyInstaller --clean --noconfirm dokogen.spec
Результат: dist/DokoGen.exe (один файл, запускается на любом Windows без Python).
"""
import sys
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
# ---------- Данные приложения ----------
datas = [
# Справочники (defense_tools.json, pd_actions.json, pd_items.json, pd_categories.json)
('dokogen/dictionaries', 'dokogen/dictionaries'),
# Пустой опросник 152-ФЗ (для «Справка → Пустой опросник»)
('dokogen/data', 'dokogen/data'),
# Иконка приложения (для окна программы в exe)
('dokogen/icon.ico', 'dokogen'),
]
# ---------- Словари pymorphy3 (обязательно!) ----------
# Без них склонение организаций/ФИО в exe не будет работать.
datas += collect_data_files('pymorphy3_dicts_ru')
datas += collect_data_files('pymorphy3')
# ---------- Скрытые импорты ----------
hiddenimports = collect_submodules('pymorphy3') + collect_submodules('pymorphy3_dicts_ru')
hiddenimports += [
'lxml', 'lxml.etree', 'lxml._elementpath',
'openpyxl', 'openpyxl.cell._writer',
]
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='DokoGen',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # GUI-приложение, без консоли
disable_windowed_traceback=False,
icon='dokogen/icon.ico',
)
+3
View File
@@ -6,5 +6,8 @@ from .declension import inflect_name, decline, company_name_decline, get_short_f
from .variables import build_replacements, VARIABLE_DEFS
from .generator import process_template
# Импорт из Excel
from . import importer
__version__ = "1.0.0"
__app_name__ = "ДоКоГеНеРаТоР"
View File
+70
View File
@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""DokoGen — настройки API для внешних сервисов проверки.
Хранятся в JSON рядом с программой (api_settings.json).
Поля:
- dadata_token — API-ключ DaData (проверка организации по ИНН)
- ai_api_key — API-ключ ИИ (OpenAI-совместимый: DeepSeek, ChatGPT и др.)
- ai_model — модель ИИ (deepseek-chat, gpt-4o-mini, gpt-4o и т.п.)
- ai_base_url — адрес API ИИ (по умолчанию DeepSeek)
"""
import os
import sys
import json
DEFAULT_SETTINGS = {
'dadata_token': '',
'ai_api_key': '',
'ai_model': 'deepseek-chat',
'ai_base_url': 'https://api.deepseek.com',
}
# Старые ключи (для совместимости со старым файлом api_settings.json)
_LEGACY_KEYS = {
'deepseek_api_key': 'ai_api_key',
'deepseek_model': 'ai_model',
}
def default_api_settings_path():
"""Путь к файлу настроек: рядом с exe (frozen) или рядом с модулем."""
if getattr(sys, 'frozen', False):
base = os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, 'api_settings.json')
def load_api_settings(path=None):
"""Загрузка настроек API из JSON-файла. Если файла нет — дефолт."""
if path is None:
path = default_api_settings_path()
if not path or not os.path.exists(path):
return dict(DEFAULT_SETTINGS)
try:
with open(path, encoding='utf-8-sig') as f:
loaded = json.load(f)
merged = dict(DEFAULT_SETTINGS)
for k, v in loaded.items():
# переносим старые ключи в новые
if k in _LEGACY_KEYS and not loaded.get(_LEGACY_KEYS[k]):
merged[_LEGACY_KEYS[k]] = v
elif k in merged:
merged[k] = v
return merged
except Exception:
return dict(DEFAULT_SETTINGS)
def save_api_settings(settings, path=None):
"""Сохранение настроек API. Возвращает True/False."""
if path is None:
path = default_api_settings_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump(settings, f, ensure_ascii=False, indent=2)
return True
except Exception:
return False
Binary file not shown.
+15 -4
View File
@@ -196,15 +196,26 @@ def _is_quoted(word: str) -> bool:
# ==================== ФОРМАТИРОВАНИЕ ФИО ====================
def _initial(word: str) -> str:
"""Инициал из слова: если уже с точкой — вернуть как есть,
иначе первая буква + точка. «В.Н.» «В.Н.», «Владимир» «В.»"""
w = word.strip()
if not w:
return ''
if w.endswith('.'):
return w
return w[0] + '.'
def get_short_fio(fio: str) -> str:
"""Фамилия И.О."""
if not fio:
return ""
parts = fio.split()
if len(parts) >= 2:
initials = parts[1][0] + "."
initials = _initial(parts[1])
if len(parts) >= 3:
initials += parts[2][0] + "."
initials += _initial(parts[2])
return parts[0] + " " + initials
return fio
@@ -215,8 +226,8 @@ def get_initials(fio: str) -> str:
return ""
parts = fio.split()
if len(parts) >= 2:
initials = parts[1][0] + "."
initials = _initial(parts[1])
if len(parts) >= 3:
initials += parts[2][0] + "."
initials += _initial(parts[2])
return initials + " " + parts[0]
return fio
+14
View File
@@ -0,0 +1,14 @@
[
"Антивирусное ПО",
"СКЗИ (криптография)",
"Межсетевой экран",
"СЗИ от НСД",
"СОВ (обнаружение вторжений)",
"Доверенная загрузка",
"SIEM-система",
"DLP-система",
"VPN",
"Система парольной защиты",
"Антиспам",
"Средства резервного копирования"
]
+22
View File
@@ -0,0 +1,22 @@
[
"Сбор персональных данных",
"Запись персональных данных",
"Систематизация персональных данных",
"Накопление персональных данных",
"Хранение персональных данных",
"Уточнение (обновление, изменение) персональных данных",
"Извлечение персональных данных",
"Использование персональных данных",
"Передача (распространение, предоставление, доступ) персональных данных",
"Обезличивание персональных данных",
"Блокирование персональных данных",
"Удаление персональных данных",
"Уничтожение персональных данных",
"Трансграничная передача персональных данных",
"Автоматизированная обработка персональных данных",
"Неавтоматизированная обработка персональных данных",
"Смешанная обработка персональных данных",
"Формирование и ведение баз данных",
"Передача данных третьим лицам",
"Внутренняя передача данных между подразделениями"
]
+8
View File
@@ -0,0 +1,8 @@
[
"работники оператора",
"клиенты",
"контрагенты",
"родственники работников",
"соискатели",
"иные лица"
]
+28
View File
@@ -0,0 +1,28 @@
[
"Фамилия, имя, отчество",
"Дата рождения",
"Место рождения",
"Паспортные данные",
"Адрес регистрации",
"Адрес проживания",
"ИНН",
"СНИЛС",
"Номер телефона",
"Адрес электронной почты",
"Образование",
"Профессия",
"Сведения о доходах",
"Семейное положение",
"Состав семьи",
"Состояние здоровья",
"Национальность",
"Гражданство",
"Сведения о судимости",
"Фотография",
"Биометрические данные",
"Рабочий e-mail",
"Должность",
"Табельный номер",
"Сведения о воинском учете",
"Сведения о командировках"
]
Binary file not shown.
+862
View File
@@ -0,0 +1,862 @@
# -*- 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_]+>')
# «Переменные», которые на самом деле не переменные, а клавиши в тексте
# инструкций (Ctrl+Alt+Del и т.п.) — их не считаем незаменёнными
IGNORED_OLD_VARS = {
'<Ctrl>', '<Alt>', '<Del>', '<Delete>', '<Shift>', '<Enter>', '<Tab>',
'<Esc>', '<Escape>', '<Insert>', '<Home>', '<End>', '<PageUp>', '<PageDown>',
'<Backspace>', '<Space>', '<CapsLock>', '<NumLock>', '<ScrollLock>',
'<PrintScreen>', '<Pause>', '<Break>', '<Up>', '<Down>', '<Left>', '<Right>',
'<F1>', '<F2>', '<F3>', '<F4>', '<F5>', '<F6>', '<F7>', '<F8>', '<F9>',
'<F10>', '<F11>', '<F12>',
}
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}}',
# Краткое наименование по падежам (старые шаблоны)
'<ShortCompanyName1>': '{{company_short_name}}',
'<ShortCompanyName2>': '{{company_short_name_genitive}}',
'<ShortCompanyName3>': '{{company_short_name_dative}}',
'<ShortCompanyName4>': '{{company_short_name_accs}}',
'<ShortCompanyName5>': '{{company_short_name_ablt}}',
'<ShortCompanyName6>': '{{company_short_name_loct}}',
'<IspdnName>': '{{is_name_1}}',
}
# ============================================================
# 2. ОБРАБОТКА ЦИКЛОВ НА УРОВНЕ ZIP/XML
# ============================================================
W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def _normalize_loop_markers(doc_xml: str) -> str:
"""Word разбивает маркеры <!-- loop:... --> и переменные {{item.x}}
на несколько run'ов (например '<!-- ' в одном <w:t>, 'loop:info' в другом,
'rmation -->' в третьем). Склеиваем текст каждого параграфа в один run,
чтобы маркеры и переменные циклов снова стали едиными."""
try:
root = etree.fromstring(doc_xml.encode('utf-8'))
except Exception:
return doc_xml
changed = False
for p in root.iter(f'{{{W_NS}}}p'):
# Собираем весь текст параграфа (по всем run'ам),
# табы <w:tab/> превращаем в символ \t, чтобы не потерять
texts = []
for child in p.iter():
tag = child.tag.split('}')[-1]
if tag == 't':
texts.append(child.text or '')
elif tag == 'tab':
texts.append('\t')
full = ''.join(texts)
# Интересуют параграфы с маркерами циклов ИЛИ любыми переменными {{...}}
if '{{' not in full and 'loop:' not in full and 'loop_end' not in full:
continue
# Был ли в параграфе разрыв страницы (Word хранит его отдельным run)
has_page_break = any(
br.get(f'{{{W_NS}}}type') == 'page'
for br in p.iter(f'{{{W_NS}}}br')
)
# Сохраняем форматирование первого run
first_r = p.find(f'{{{W_NS}}}r')
rpr = None
if first_r is not None:
rpr_el = first_r.find(f'{{{W_NS}}}rPr')
if rpr_el is not None:
rpr = etree.fromstring(etree.tostring(rpr_el))
# Оставляем только pPr, удаляем все run'ы
for child in list(p):
if child.tag != f'{{{W_NS}}}pPr':
p.remove(child)
# Разрыв страницы — отдельным run ПЕРЕД текстом (как было в оригинале)
if has_page_break:
br_r = etree.SubElement(p, f'{{{W_NS}}}r')
if rpr is not None:
br_r.append(etree.fromstring(etree.tostring(rpr)))
br_el = etree.SubElement(br_r, f'{{{W_NS}}}br')
br_el.set(f'{{{W_NS}}}type', 'page')
# Создаём run с полным текстом параграфа, восстанавливая табы <w:tab/>
new_r = etree.SubElement(p, f'{{{W_NS}}}r')
if rpr is not None:
new_r.append(rpr)
# Разбиваем по \t: обычный текст → <w:t>, таб → <w:tab/>
parts = full.split('\t')
for i, part in enumerate(parts):
if part:
new_t = etree.SubElement(new_r, f'{{{W_NS}}}t')
new_t.text = part
new_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
if i < len(parts) - 1:
tab_el = etree.SubElement(new_r, f'{{{W_NS}}}tab')
changed = True
if not changed:
return doc_xml
return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True).decode('utf-8')
def _strip_edge_empty_paras(fragment: str) -> str:
"""Удаляет пустые параграфы (без текста) с начала и конца XML-фрагмента.
Word оставляет их вокруг маркеров <!-- loop:... -->, из-за чего
при размножении цикла между строками появляются пустые строки."""
para_re = re.compile(r'<w:p\b[^>]*?(?:/>|>.*?</w:p>)', re.S)
def _is_empty(p: str) -> bool:
# Не пустой, если есть разрыв страницы, рисунок, таблица и т.п.
if '<w:br' in p or '<w:drawing' in p or '<w:tbl' in p or '<w:object' in p:
return False
texts = re.findall(r'<w:t[^>]*>(.*?)</w:t>', p, re.S)
if not texts:
return True
return all(t.strip() == '' for t in texts)
# С начала
while True:
m = para_re.match(fragment)
if not m:
break
if _is_empty(m.group(0)):
fragment = fragment[m.end():]
else:
break
# С конца
while True:
m = para_re.search(fragment)
if not m:
break
if m.end() != len(fragment):
break
if _is_empty(m.group(0)):
fragment = fragment[:m.start()]
else:
break
return fragment
def _top_level_ranges(fragment: str):
"""Возвращает список (start, end) ПОЛНЫХ элементов верхнего уровня
(w:p, w:tbl) в XML-фрагменте. Таблицы считаются одним элементом.
Обрывки (незакрытые <w:p в конце фрагмента) не включаются —
они относятся к параграфу маркера конца (suffix)."""
ranges = []
i = 0
n = len(fragment)
while i < n:
if fragment.startswith('<w:tbl>', i) or fragment.startswith('<w:tbl ', i):
j = i
d = 0
while j < n:
if fragment.startswith('<w:tbl>', j) or fragment.startswith('<w:tbl ', j):
d += 1
j += 5
elif fragment.startswith('</w:tbl>', j):
d -= 1
j += 8
if d == 0:
break
else:
j += 1
ranges.append((i, j))
i = j
elif fragment.startswith('<w:p>', i) or fragment.startswith('<w:p ', i):
j = fragment.find('</w:p>', i)
if j == -1:
break # обрывок — не полный параграф, дальше suffix
j += 6
ranges.append((i, j))
i = j
else:
i += 1
return ranges
def _split_template_edges(template: str):
"""Разделяет шаблон цикла на три части:
prefix — хвост параграфа маркера начала (после -->),
core — полные элементы верхнего уровня (параграфы/таблицы) между маркерами,
suffix — начало параграфа маркера конца (до <!--)."""
ranges = _top_level_ranges(template)
if not ranges:
return '', template, ''
prefix = template[:ranges[0][0]]
suffix = template[ranges[-1][1]:]
core = template[ranges[0][0]:ranges[-1][1]]
return prefix, core, suffix
def _cleanup_marker_paras(doc_xml: str) -> str:
"""Удаляет параграфы-остатки маркеров циклов: <w:p>...<w:r><w:t></w:t></w:r></w:p>
(пустой run — бывший маркер <!-- loop:... -->). Намеренные пустые строки
(<w:p/> без run'ов) не трогаем."""
try:
root = etree.fromstring(doc_xml.encode('utf-8'))
except Exception:
return doc_xml
removed = False
for p in list(root.iter(f'{{{W_NS}}}p')):
# Не трогаем параграфы внутри таблиц (пустые ячейки валидны)
parent = p.getparent()
if parent is None:
continue
if parent.tag == f'{{{W_NS}}}tc':
continue
# Не трогаем параграфы с разрывом страницы/строки, рисунками и т.п.
if (p.find(f'{{{W_NS}}}r/{{{W_NS}}}br') is not None
or p.find(f'{{{W_NS}}}r/{{{W_NS}}}drawing') is not None
or p.find(f'{{{W_NS}}}r/{{{W_NS}}}object') is not None
or p.find(f'{{{W_NS}}}r/{{{W_NS}}}pict') is not None):
continue
# Есть непустой текст — не трогаем
texts = [t.text or '' for t in p.iter(f'{{{W_NS}}}t')]
if any(t.strip() for t in texts):
continue
# Есть run'ы (признак бывшего маркера), но текста нет — удаляем
runs = p.findall(f'{{{W_NS}}}r')
if not runs:
continue
parent.remove(p)
removed = True
if not removed:
return doc_xml
return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True).decode('utf-8')
def _subst_var_xml(xml: str, var: str, val: str) -> str:
"""Замена {{var}} в XML-фрагменте.
Пустые значения («—»/«») подсвечиваются жёлтым маркером: значение
выносится в отдельный run с <w:highlight w:val="yellow"/>, чтобы
подсветка не затиралась при подстановке на уровне XML (циклы).
"""
token = '{{%s}}' % var
if val not in ('', ''):
return xml.replace(token, val)
hl_run = ('<w:rPr><w:highlight w:val="yellow"/></w:rPr>'
'<w:t xml:space="preserve">%s</w:t>' % val)
return xml.replace(token,
'</w:t></w:r><w:r>' + hl_run
+ '</w:r><w:r><w:t xml:space="preserve">')
def _expand_nested_loops(template: str, item: Dict) -> str:
"""Разворачивает вложенные циклы вида <!-- loop:users_loop --> ... <!-- loop_end -->
данными из item (список словарей). Используется для циклов внутри цикла ИС,
например пользователи ИС. Переменные внутри: {{user_fio}}, {{user_position}}."""
result = template
for _ in range(20):
m_start = LOOP_START.search(result)
if not m_start:
break
key = m_start.group(1)
data = item.get(key)
if not isinstance(data, list):
# Нет данных для вложенного цикла — удаляем блок целиком
depth = 1
pos = m_start.end()
m_end = None
for mm in LOOP_END.finditer(result, pos):
depth -= 1
if depth == 0:
m_end = mm
break
if m_end:
result = result[:m_start.start()] + result[m_end.end():]
continue
# Ищем соответствующий loop_end с учётом вложенности
depth = 1
pos = m_start.end()
m_end = None
markers = sorted(
[('s', x) for x in LOOP_START.finditer(result, pos)] +
[('e', x) for x in LOOP_END.finditer(result, pos)],
key=lambda t: t[1].start()
)
for kind, mm in markers:
if kind == 's':
depth += 1
else:
depth -= 1
if depth == 0:
m_end = mm
break
if m_end is None:
break
inner = result[m_start.end():m_end.start()]
parts = []
for sub in data:
clone = inner
for f, v in sub.items():
val = str(v) if v else ''
clone = _subst_var_xml(clone, 'user_%s' % f, val)
clone = _subst_var_xml(clone, 'item.%s' % f, val)
parts.append(clone)
result = result[:m_start.start()] + ''.join(parts) + result[m_end.end():]
return result
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()
wrote = False # была ли хоть одна запись в tmp
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
# Word мог разбить маркеры циклов на несколько run'ов — склеиваем
doc_xml = _normalize_loop_markers(doc_xml)
# Находим ВСЕ маркеры циклов
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)
wrote = True
continue
# Извлекаем шаблон (всё между start и end маркерами)
template = doc_xml[outer.end():outer_end.start()]
# Word оставляет обрывки параграфов вокруг маркеров и пустые параграфы.
# Отделяем обрывки (prefix/suffix) от тела цикла и чистим пустые
# параграфы в теле — иначе между итерациями появляются пустые строки.
prefix, core, suffix = _split_template_edges(template)
core = _strip_edge_empty_paras(core)
# НЕ удаляем loop_end из шаблона — они принадлежат вложенным циклам!
template_clean = core
# Разрыв страницы рядом с маркером конца цикла (Word хранит его
# отдельным run в параграфе с <!-- loop_end -->). Если он есть —
# каждый акт/блок цикла должен начинаться с новой страницы.
probe = doc_xml[max(0, outer_end.start() - 800):outer_end.end()]
has_page_break = '<w:br w:type="page"/>' in probe
# Если разрыв живёт в «хвосте» (параграф loop_end) — выносим его
# между итерациями, а из хвоста убираем (иначе акты слипаются).
br_in_suffix = has_page_break and '<w:br w:type="page"/>' in suffix
if br_in_suffix:
suffix = re.sub(r'<w:br\b[^>]*?w:type="page"[^>]*?/>', '', suffix)
# Размножаем шаблон для каждого элемента данных
expanded_parts = []
for idx, item in enumerate(items, 1):
clone = template_clean
# Вложенные циклы (users_loop и т.п.) — разворачиваем ДО подстановки
# обычных полей, чтобы {{user_fio}} внутри них не затёрлись алиасами
clone = _expand_nested_loops(clone, item)
for field, value in item.items():
if isinstance(value, list):
continue # списки — данные вложенных циклов, не подставляем
val_str = str(value) if value else ('' if value is None else '')
# С префиксом item.
clone = _subst_var_xml(clone, 'item.%s' % field, val_str)
# Без префикса (алиасы {{name}}, {{pd_list}}, {{document}} и т.д.)
clone = _subst_var_xml(clone, '%s' % field, val_str)
# Альтернативный синтаксис {{item_поле}} (без точки)
clone = _subst_var_xml(clone, 'item_%s' % field, val_str)
clone = _subst_var_xml(clone, 'item.number', str(idx))
clone = _subst_var_xml(clone, 'number', str(idx))
expanded_parts.append(clone)
if br_in_suffix:
expanded_parts.append('<w:p><w:r><w:br w:type="page"/></w:r></w:p>')
# Собираем новый XML: обрывок параграфа маркера начала (prefix) один раз,
# затем размноженное тело цикла, затем обрывок параграфа маркера конца (suffix)
new_xml = (doc_xml[:outer.start()] + prefix
+ ''.join(expanded_parts) + suffix
+ 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)
wrote = True
if not wrote:
# Циклов в шаблоне не было — возвращаем исходный файл,
# пустой временный удаляем (иначе python-docx упадёт с Package not found)
try:
os.unlink(tmp.name)
except Exception:
pass
return docx_path
# Финальная зачистка: пустые параграфы-остатки маркеров (<!-- loop:... -->)
try:
with zipfile.ZipFile(tmp.name, 'r') as z:
all_files = {name: z.read(name) for name in z.namelist()}
doc_xml = all_files.get('word/document.xml', b'').decode('utf-8')
cleaned = _cleanup_marker_paras(doc_xml)
if cleaned != doc_xml:
all_files['word/document.xml'] = cleaned.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)
except Exception:
pass
return tmp.name
# ============================================================
# 3. ЗАМЕНА ПЕРЕМЕННЫХ В ЭЛЕМЕНТАХ DOCX
# ============================================================
def _apply_highlight_color(run_el, color: str):
"""Добавляет маркер highlight указанного цвета к run."""
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
rpr = run_el.find(f'{{{ns}}}rPr')
if rpr is None:
rpr = OxmlElement('w:rPr')
run_el.insert(0, rpr)
hl = OxmlElement('w:highlight')
hl.set(f'{{{ns}}}val', color)
rpr.append(hl)
def _add_run_with_tabs(para, text, style_source=None):
"""Добавляет run с текстом, сохраняя табуляцию как <w:tab/>.
python-docx add_run('\t') вставляет символ таба в w:t, который Word
может «съесть». Поэтому разбиваем текст по '\t' и вставляем
настоящие элементы табуляции.
"""
parts = str(text).split('\t')
for i, part in enumerate(parts):
if part:
run = para.add_run(part)
if style_source is not None:
_copy_run_style(style_source, run)
if i < len(parts) - 1:
run = para.add_run()
if style_source is not None:
_copy_run_style(style_source, run)
run.add_tab()
def _replace_text_in_para(para, replacements: Dict[str, str]) -> bool:
"""Заменяет {{var}} в параграфе. Возвращает True, если были замены.
Значения «—» (незаполненная информация) подсвечиваются жёлтым маркером.
Табуляция в параграфе сохраняется (<w:tab/>).
"""
full = para.text
matches = list(VAR_PATTERN.finditer(full))
if not matches:
return False
# Собираем параграф заново: обычный текст + значения переменных
first = para.runs[0] if para.runs else None
para.clear()
pos = 0
changed = False
for m in matches:
var = m.group(0)
val = replacements.get(var)
# текст до переменной
if m.start() > pos:
_add_run_with_tabs(para, full[pos:m.start()], first)
if val is None:
# Переменная не заменена — оставляем как есть,
# красным её подсветит _highlight_unmatched на шаге 7
run = para.add_run(var)
if first is not None:
_copy_run_style(first, run)
pos = m.end()
continue
# значение переменной
val_str = str(val)
run = para.add_run(val_str)
if first is not None:
_copy_run_style(first, run)
if val_str == '' or val_str == '':
_apply_highlight_color(run._element, 'yellow')
changed = True
pos = m.end()
# хвост после последней переменной
if pos < len(full):
_add_run_with_tabs(para, full[pos:], first)
return changed
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 _apply_highlight(run_el):
rpr = run_el.find(f'{{{ns}}}rPr')
if rpr is None:
rpr = OxmlElement('w:rPr')
run_el.insert(0, rpr)
hl = OxmlElement('w:highlight')
hl.set(f'{{{ns}}}val', 'red')
rpr.append(hl)
def _process_para(para):
full = para.text
matches = [m for m in UNMATCHED_PATTERN.finditer(full)
if m.group(0) not in IGNORED_OLD_VARS]
if not matches:
return
# Пересобираем параграф: обычный текст и переменные отдельными run'ами,
# переменные — с красным маркером. Сохраняем стиль первого run и табы.
first = para.runs[0] if para.runs else None
para.clear()
pos = 0
for m in matches:
if m.start() > pos:
_add_run_with_tabs(para, full[pos:m.start()], first)
run = para.add_run(m.group(0))
if first is not None:
_copy_run_style(first, run)
_apply_highlight(run._element)
pos = m.end()
if pos < len(full):
_add_run_with_tabs(para, full[pos:], first)
run = para.add_run(full[pos:])
if first is not None:
_copy_run_style(first, run)
for para in doc.paragraphs:
_process_para(para)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
_process_para(para)
for section in doc.sections:
for hf in [section.header, section.footer,
section.first_page_header, section.first_page_footer]:
if hf is not None:
for para in hf.paragraphs:
_process_para(para)
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 in IGNORED_OLD_VARS:
continue
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
+281
View File
@@ -0,0 +1,281 @@
# -*- coding: utf-8 -*-
"""DokoGen — справка и инструкции (Word) + пустой опросник (Excel).
Файлы генерируются при первом обращении и кладутся в папку docs/
рядом с программой (при сборке .exe — рядом с exe):
- Инструкция_пользователя.docx
- Инструкция_администратора.docx
- Опросный_лист_152_ФЗ.xlsx (пустой шаблон)
"""
import os
import sys
import shutil
try:
import docx
from docx.shared import Pt, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
DOCX_AVAILABLE = True
except ImportError:
DOCX_AVAILABLE = False
def docs_dir():
"""Папка docs рядом с exe (frozen) или рядом с модулем."""
if getattr(sys, 'frozen', False):
base = os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.abspath(__file__))
d = os.path.join(base, 'docs')
os.makedirs(d, exist_ok=True)
return d
def user_manual_path():
return os.path.join(docs_dir(), 'Инструкция_пользователя.docx')
def admin_manual_path():
return os.path.join(docs_dir(), 'Инструкция_администратора.docx')
def variables_reference_path():
return os.path.join(docs_dir(), 'Переменные_шаблонов.docx')
def survey_path():
return os.path.join(docs_dir(), 'Опросный_лист_152_ФЗ.xlsx')
def _h(doc, text, size=14):
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = True
run.font.size = Pt(size)
return p
def _p(doc, text, bold=False):
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = bold
run.font.size = Pt(11)
return p
def _bullet(doc, text):
p = doc.add_paragraph(style='List Bullet')
run = p.add_run(text)
run.font.size = Pt(11)
return p
def _generate_variables_reference(path):
"""Справочник переменных шаблонов (Word)."""
try:
from .variables import VARIABLE_DEFS
except Exception:
VARIABLE_DEFS = []
doc = docx.Document()
_h(doc, 'Переменные шаблонов')
_p(doc, 'Список переменных для шаблонов Word. Переменная подставляется в текст '
'как есть: {{имя_переменной}}. Циклы: <!-- loop:имя --> ... <!-- loop_end -->.')
groups = {'A': 'Общие переменные', 'B': '152-ФЗ (персональные данные)'}
by_group = {'A': [], 'B': []}
for var, group, desc, _ in VARIABLE_DEFS:
by_group.setdefault(group, []).append((var, desc))
for gkey, title in groups.items():
items = by_group.get(gkey, [])
if not items:
continue
_h(doc, title, 12)
for var, desc in items:
_bullet(doc, f'{var}{desc}')
_h(doc, 'Циклы', 12)
_bullet(doc, 'information_systems — ИС ({{item.name}}, {{item.description}}, {{item.structure}}, {{item.pd_actions}}, {{item.defense_tools_list}}, {{item.users}}, {{item.category}}, {{item.pd_count}} и др.).')
_bullet(doc, 'commission — комиссия ({{item.role}}, {{item.position}}, {{item.fio}}, {{item.fio_initials}}, {{item.fio_short}}, {{item.signature}}).')
_bullet(doc, 'employees_access — сотрудники с доступом ({{item.position}}, {{item.fio}}).')
_bullet(doc, 'paper_documents — бумажные документы ({{item.document}}, {{item.storage}}).')
doc.save(path)
def _generate_user_manual(path):
"""Инструкция пользователя: программа, циклы, переменные, проверки."""
doc = docx.Document()
_h(doc, 'ДоКоГеНеРаТоР — 152-ФЗ')
_h(doc, 'Инструкция пользователя', 13)
_h(doc, '1. Что это за программа', 12)
_p(doc, 'Программа автоматически заполняет шаблоны документов по 152-ФЗ '
'(акты обследования, политики, инструкции, приказы и т.д.) данными '
'об организации, информационных системах, комиссии и сотрудниках. '
'Данные можно ввести вручную на вкладках или импортировать из '
'опросного листа Excel.')
_h(doc, '2. Основные вкладки', 12)
_bullet(doc, 'Организация — реквизиты, адреса, руководитель, склонения названий, проверка данных (Морфер / DaData / ИИ).')
_bullet(doc, 'Информационные системы — ИС, категории ПДн, действия с ПДн, средства защиты, пользователи.')
_bullet(doc, 'Комиссия — состав комиссии по классификации ИСПДн.')
_bullet(doc, 'Пользователи — сотрудники с доступом к ПДн.')
_bullet(doc, 'Бумажные документы — бумажные носители ПДн и места хранения.')
_bullet(doc, 'Генерация — выбор шаблонов и запуск генерации.')
_bullet(doc, 'Лог — журнал действий, экспорт в файл.')
_h(doc, '3. Переменные в шаблонах', 12)
_p(doc, 'В шаблонах Word используются переменные в двойных фигурных скобках, '
'например {{company_full_name}}, {{inn}}, {{chief_fio_initials}}. '
'Полный список — в пункте меню «Справка → Переменные шаблонов».')
_p(doc, 'Переменные, которые не удалось заменить (нет данных), подсвечиваются '
'КРАСНЫМ маркером. Незаполненная информация заменяется прочерком «—» '
'и подсвечивается ЖЁЛТЫМ.')
_h(doc, '4. Циклы (повторяющиеся блоки)', 12)
_p(doc, 'Если в документе нужно повторить блок для каждой ИС, комиссии, '
'сотрудника или бумажного документа — используются маркеры цикла:')
_p(doc, '<!-- loop:information_systems --> ... {{item.name}} ... <!-- loop_end -->', bold=True)
_p(doc, 'Доступные циклы:')
_bullet(doc, 'information_systems — ИС ({{item.name}}, {{item.description}}, {{item.structure}}, {{item.pd_actions}}, {{item.defense_tools_list}}, {{item.users}}, {{item.category}}, {{item.pd_count}} и др.).')
_bullet(doc, 'commission — комиссия ({{item.role}}, {{item.position}}, {{item.fio}}, {{item.fio_initials}}, {{item.fio_short}}, {{item.signature}}).')
_bullet(doc, 'employees_access — сотрудники с доступом ({{item.position}}, {{item.fio}}).')
_bullet(doc, 'paper_documents — бумажные документы ({{item.document}}, {{item.storage}}).')
_p(doc, 'Внутри цикла переменные пишутся через точку: {{item.поле}}. '
'Допускается и синтаксис без точки: {{item_поле}}.')
_h(doc, '5. Импорт из опросника', 12)
_p(doc, 'Меню «Генерация → Импорт из опросника» (или кнопка «📥 Импорт»). '
'Выберите заполненный опросный лист Excel — данные подтянутся '
'автоматически. Что именно импортировать и откуда — настраивается '
'в файле import_settings.json (см. инструкцию администратора).')
_h(doc, '6. Проверка данных', 12)
_bullet(doc, 'Склонение — Морфер 3.0 (бесплатно, без ключа) или ИИ.')
_bullet(doc, 'Реквизиты — DaData (по ИНН) или ИИ.')
_bullet(doc, 'ИИ-результаты помечаются дисклеймером «сгенерировано ИИ — перепроверьте».')
_bullet(doc, 'Ключи API задаются в меню «Файл → Настройки».')
_h(doc, '7. Генерация', 12)
_p(doc, '1) Укажите папку с шаблонами (кнопка «📁 Шаблоны»).\n'
'2) Выберите нужные шаблоны в списке.\n'
'3) Укажите папку для результатов.\n'
'4) Нажмите «⚡ Генерация».')
_p(doc, 'Результаты сохраняются в выбранную папку. Если какой-то документ '
'не создался — смотрите вкладку «Лог»: там будет причина.')
doc.save(path)
def _generate_admin_manual(path):
"""Инструкция администратора: словари, API-ключи, лимиты."""
doc = docx.Document()
_h(doc, 'ДоКоГеНеРаТоР — 152-ФЗ')
_h(doc, 'Инструкция администратора', 13)
_h(doc, '1. Файлы настроек рядом с программой', 12)
_bullet(doc, 'api_settings.json — API-ключи (DaData, ИИ).')
_bullet(doc, 'import_settings.json — словарь импорта: что и откуда брать из опросника.')
_bullet(doc, 'dictionaries/ — справочники: defense_tools.json, pd_actions.json, pd_items.json, pd_categories.json.')
_bullet(doc, 'dokogen.log — журнал работы (пишется автоматически).')
_bullet(doc, 'docs/ — инструкции и пустой опросник (создаются автоматически).')
_h(doc, '2. Словари (папка dictionaries)', 12)
_p(doc, 'JSON-файлы со списками значений для выпадающих списков. '
'Формат — простой массив строк:')
_p(doc, '[\n "Антивирусное ПО",\n "СКЗИ (криптография)",\n "Межсетевой экран"\n]', bold=True)
_p(doc, 'Чтобы добавить значение — откройте файл в Блокноте, добавьте строку '
'через запятую и сохраните. Программа перечитает файл при следующем '
'открытии окна выбора.')
_h(doc, '3. Словарь импорта (import_settings.json)', 12)
_p(doc, 'Определяет, какие поля опросника импортируются и откуда. Каждое правило:')
_p(doc, '{"name": "Полное наименование организации", "var": "fullName", '
'"cell": "\'Общие сведения\'!B2", "keywords": "полное наименование организации"}', bold=True)
_bullet(doc, 'cell — конкретная ячейка (если заполнена — берём оттуда);')
_bullet(doc, 'keywords — регулярное выражение для поиска строки-вопроса в колонке A;')
_bullet(doc, 'var — ключ в данных программы (fullName, inn, kpp, ogrn и т.д.).')
_p(doc, 'Также настраиваются: common_sheet_keyword (лист «Общие сведения»), '
'employees_sheet_keyword (лист «Сотрудники»), is_sheet_exclude_keywords '
'(какие листы НЕ ИС), value_column (колонка значения, 1 = B).')
_h(doc, '4. API-ключи (меню «Файл → Настройки»)', 12)
_p(doc, 'DaData — проверка организации по ИНН/ОГРН (наименование, адрес, КПП, руководитель).')
_bullet(doc, 'Где взять: https://dadata.ru → регистрация → раздел API. Бесплатный тариф ~10 000 запросов в день.')
_bullet(doc, 'Ключ: секция «Статический API-ключ» (формат: буквы и цифры).')
_p(doc, 'ИИ (OpenAI-совместимый: DeepSeek, ChatGPT и др.) — проверка склонений и реквизитов.')
_bullet(doc, 'DeepSeek: https://platform.deepseek.com → API Keys. Модель deepseek-chat. Дешёвая, ключ пополняется деньгами.')
_bullet(doc, 'ChatGPT/OpenAI: https://platform.openai.com → API Keys. Модели gpt-4o-mini, gpt-4o.')
_bullet(doc, 'Базовый URL: для DeepSeek можно оставить пустым; для OpenAI — https://api.openai.com.')
_p(doc, 'Лимиты запросов: зависят от тарифа. DaData — ~10 000/сутки на бесплатном тарифе. '
'DeepSeek/OpenAI — оплата за токены, лимиты настраиваются в личном кабинете. '
'Рекомендуется не превышать несколько сотен проверок в день, чтобы не упереться в лимиты.')
_h(doc, '5. Морфер 3.0', 12)
_p(doc, 'Бесплатный веб-сервис склонения слов и ФИО. Ключ не требуется — '
'программа обращается к https://ws3.morpher.ru автоматически.')
_h(doc, '6. Логи', 12)
_p(doc, 'Все действия (импорт, генерация, обращения к Морфер/DaData/ИИ с ответами) '
'записываются в файл dokogen.log рядом с программой. Экспорт лога — '
'кнопка «💾 Экспорт логов» на вкладке «Лог». При проблемах присылайте '
'этот файл разработчику.')
_h(doc, '7. Сборка .exe', 12)
_p(doc, 'Программа собирается с PyInstaller: pyinstaller --onefile --windowed main.py. '
'Файлы настроек (api_settings.json, import_settings.json, dictionaries/, docs/) '
'создаются автоматически рядом с .exe при первом запуске и остаются '
'редактируемыми.')
doc.save(path)
def _ensure_survey():
"""Копирует пустой опросник в docs/, если его ещё нет."""
target = survey_path()
if os.path.exists(target):
return target
# Ищем шаблон опросника: рядом с программой или встроенный
candidates = [
os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'Опросный_лист_152_ФЗ.xlsx'),
os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Опросный_лист_152_ФЗ.xlsx'),
]
for c in candidates:
if os.path.exists(c):
shutil.copy(c, target)
return target
return None
def ensure_docs():
"""Создаёт инструкции и опросник при первом обращении."""
created = []
if DOCX_AVAILABLE:
up = user_manual_path()
if not os.path.exists(up):
try:
_generate_user_manual(up)
created.append(up)
except Exception:
pass
ap = admin_manual_path()
if not os.path.exists(ap):
try:
_generate_admin_manual(ap)
created.append(ap)
except Exception:
pass
vp = variables_reference_path()
if not os.path.exists(vp):
try:
_generate_variables_reference(vp)
created.append(vp)
except Exception:
pass
sp = _ensure_survey()
if sp and os.path.exists(sp):
created.append(sp)
return created
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+209
View File
@@ -0,0 +1,209 @@
{
"common_sheet_keyword": "общие",
"employees_sheet_keyword": "сотруд",
"is_sheet_exclude_keywords": [
"общие сведения",
"сотрудники"
],
"value_column": 1,
"fields": [
{
"name": "Полное наименование организации",
"var": "fullName",
"cell": "",
"keywords": "полное наименование организации"
},
{
"name": "Сокращённое наименование организации",
"var": "shortName",
"cell": "",
"keywords": "сокращенное наименование организации"
},
{
"name": "Юридический адрес",
"var": "addressLegal",
"cell": "",
"keywords": "адрес организации \\(юридический\\)"
},
{
"name": "Фактический адрес",
"var": "addressActual",
"cell": "",
"keywords": "адрес организации \\(фактический\\)"
},
{
"name": "ИНН",
"var": "inn",
"cell": "",
"keywords": "инн"
},
{
"name": "Дата ОГРН",
"var": "ogrnDate",
"cell": "",
"keywords": "дата.*огрн"
},
{
"name": "ОГРН",
"var": "ogrn",
"cell": "",
"keywords": "огрн"
},
{
"name": "КПП",
"var": "kpp",
"cell": "",
"keywords": "кпп"
},
{
"name": "Основной ОКВЭД",
"var": "okved",
"cell": "",
"keywords": "основной оквэд"
},
{
"name": "Должность руководителя",
"var": "chiefPosition",
"cell": "",
"keywords": "должность руководителя"
},
{
"name": "ФИО руководителя",
"var": "chiefFio",
"cell": "",
"keywords": "фио руководителя"
},
{
"name": "Должность ответственного за ПДн",
"var": "ispdnPosition",
"cell": "",
"keywords": "должность.*защит.*информ|должность.*администрат"
},
{
"name": "ФИО ответственного за ПДн",
"var": "ispdnFio",
"cell": "",
"keywords": "фио.*защит.*информ|фио.*администрат"
},
{
"name": "Email ответственного за ПДн",
"var": "ispdnEmail",
"cell": "",
"keywords": "электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист"
},
{
"name": "Телефон ответственного за ПДн",
"var": "ispdnPhone",
"cell": "",
"keywords": "телефон.*защит|телефон.*ответств|телефон.*специалист"
},
{
"name": "Структурное подразделение по безопасности",
"var": "ispdnDepartment",
"cell": "",
"keywords": "структур.*подраздел"
},
{
"name": "Номер договора",
"var": "contractNumber",
"cell": "",
"keywords": "номер.*договора|номер.*контракта"
},
{
"name": "Дата договора",
"var": "contractDate",
"cell": "",
"keywords": "дата.*договора|дата.*контракта"
},
{
"name": "Перечень бумажных документов",
"var": "paperDocuments",
"cell": "",
"keywords": "названи.*документ"
},
{
"name": "Место хранения документов",
"var": "paperStorage",
"cell": "",
"keywords": "место.*хранен"
},
{
"name": "Должность ответственного за обработку ПДн",
"var": "responsiblePosition",
"cell": "",
"keywords": "должность.*ответственн"
},
{
"name": "ФИО ответственного за обработку ПДн",
"var": "responsibleFio",
"cell": "",
"keywords": "фио.*ответственн"
},
{
"name": "Должность администратора безопасности",
"var": "administratorPosition",
"cell": "",
"keywords": "должность.*администрат.*(?:безопас|защит|пд)"
},
{
"name": "ФИО администратора безопасности",
"var": "administratorFio",
"cell": "",
"keywords": "фио.*администрат.*(?:безопас|защит|пд)"
},
{
"name": "Сайт организации",
"var": "site_name",
"cell": "",
"keywords": "сайт организации"
},
{
"name": "Должность ответственного за сайт",
"var": "site_responsible_position",
"cell": "",
"keywords": "должность ответственного за сайт|должность.*отв.*сайт"
},
{
"name": "ФИО ответственного за сайт",
"var": "site_responsible_fio",
"cell": "",
"keywords": "фио.*отв.*сайт|фио.*сайт"
},
{
"name": "Разработчик сайта",
"var": "site_service_provider",
"cell": "",
"keywords": "наименование организации.*разрабат.*сайт|наименование.*размещала.*сайт|разрабат.*сайт.*организац"
},
{
"name": "ИНН разработчика сайта",
"var": "site_service_provider_inn",
"cell": "",
"keywords": "инн.*разрабат.*сайт|инн.*размещала.*сайт"
},
{
"name": "Размещение сайта (хостинг)",
"var": "site_hosting",
"cell": "",
"keywords": "размещение сайта|адрес расположения серверов|адрес.*сервер.*сайт"
},
{
"name": "Адрес хостинга",
"var": "site_hosting_address",
"cell": "",
"keywords": "адрес.*хостинг|адрес.*располож.*сервер"
},
{
"name": "Электронная почта",
"var": "email",
"cell": "",
"keywords": "электронная почта"
},
{
"name": "Телефон",
"var": "phone",
"cell": "",
"keywords": "телефон"
}
]
}
+174
View File
@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
"""DokoGen — настройки импорта из опросника (редактируемый словарь).
Идея: «что импортировать и откуда» хранится НЕ в коде, а в текстовом
JSON-файле рядом с программой (import_settings.json). При сборке .exe
файл остаётся редактируемым — можно править пути к ячейкам и ключевые
слова без пересборки.
Формат файла:
{
"common_sheet_keyword": "общие", // ключевое слово листа «Общие сведения»
"employees_sheet_keyword": "сотруд", // ключевое слово листа «Сотрудники»
"is_sheet_exclude_keywords": ["общие сведения", "сотрудники"], // листы, которые НЕ ИС
"value_column": 1, // колонка значения (0=A, 1=B, ...)
"fields": [
{
"name": "Полное наименование организации", // человекочитаемое название
"var": "fullName", // ключ в данных программы
"cell": "'Общие сведения'!B2", // (необязательно) конкретная ячейка
"keywords": "полное наименование организации" // (необязательно) регэксп поиска по колонке A
}
]
}
Порядок применения для каждого поля:
1) если задана cell и ячейка заполнена — берём значение оттуда;
2) иначе ищем строку, где колонка A совпадает с keywords (регэксп),
и берём значение из колонки value_column.
"""
import os
import re
import sys
import json
# ============================================================
# ПУТЬ К ФАЙЛУ НАСТРОЕК ПО УМОЛЧАНИЮ
# ============================================================
def default_import_settings_path():
"""Путь к файлу настроек: рядом с exe (frozen) или рядом с модулем."""
if getattr(sys, 'frozen', False):
base = os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, 'import_settings.json')
# ============================================================
# ДЕФОЛТНЫЙ СЛОВАРЬ (используется, если файл не найден)
# ============================================================
def _f(name, var, cell='', keywords=''):
return {'name': name, 'var': var, 'cell': cell, 'keywords': keywords}
DEFAULT_SETTINGS = {
'common_sheet_keyword': 'общие',
'employees_sheet_keyword': 'сотруд',
'is_sheet_exclude_keywords': ['общие сведения', 'сотрудники'],
'value_column': 1,
'fields': [
_f('Полное наименование организации', 'fullName', keywords=r'полное наименование организации'),
_f('Сокращённое наименование организации', 'shortName', keywords=r'сокращенное наименование организации'),
_f('Юридический адрес', 'addressLegal', keywords=r'адрес организации \(юридический\)'),
_f('Фактический адрес', 'addressActual', keywords=r'адрес организации \(фактический\)'),
_f('ИНН', 'inn', keywords=r'инн'),
_f('Дата ОГРН', 'ogrnDate', keywords=r'дата.*огрн'),
_f('ОГРН', 'ogrn', keywords=r'огрн'),
_f('КПП', 'kpp', keywords=r'кпп'),
_f('Основной ОКВЭД', 'okved', keywords=r'основной оквэд'),
_f('Должность руководителя', 'chiefPosition', keywords=r'должность руководителя'),
_f('ФИО руководителя', 'chiefFio', keywords=r'фио руководителя'),
_f('Должность ответственного за ПДн', 'ispdnPosition', keywords=r'должность.*защит.*информ|должность.*администрат'),
_f('ФИО ответственного за ПДн', 'ispdnFio', keywords=r'фио.*защит.*информ|фио.*администрат'),
_f('Email ответственного за ПДн', 'ispdnEmail', keywords=r'электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист'),
_f('Телефон ответственного за ПДн', 'ispdnPhone', keywords=r'телефон.*защит|телефон.*ответств|телефон.*специалист'),
_f('Структурное подразделение по безопасности', 'ispdnDepartment', keywords=r'структур.*подраздел'),
_f('Номер договора', 'contractNumber', keywords=r'номер.*договора|номер.*контракта'),
_f('Дата договора', 'contractDate', keywords=r'дата.*договора|дата.*контракта'),
_f('Перечень бумажных документов', 'paperDocuments', keywords=r'названи.*документ'),
_f('Место хранения документов', 'paperStorage', keywords=r'место.*хранен'),
_f('Должность ответственного за обработку ПДн', 'responsiblePosition', keywords=r'должность.*ответственн'),
_f('ФИО ответственного за обработку ПДн', 'responsibleFio', keywords=r'фио.*ответственн'),
_f('Должность администратора безопасности', 'administratorPosition', keywords=r'должность.*администрат.*(?:безопас|защит|пд)'),
_f('ФИО администратора безопасности', 'administratorFio', keywords=r'фио.*администрат.*(?:безопас|защит|пд)'),
_f('Сайт организации', 'site_name', keywords=r'сайт организации'),
_f('Должность ответственного за сайт', 'site_responsible_position', keywords=r'должность ответственного за сайт|должность.*отв.*сайт'),
_f('ФИО ответственного за сайт', 'site_responsible_fio', keywords=r'фио.*отв.*сайт|фио.*сайт'),
_f('Разработчик сайта', 'site_service_provider', keywords=r'наименование организации.*разрабат.*сайт|наименование.*размещала.*сайт|разрабат.*сайт.*организац'),
_f('ИНН разработчика сайта', 'site_service_provider_inn', keywords=r'инн.*разрабат.*сайт|инн.*размещала.*сайт'),
_f('Размещение сайта (хостинг)', 'site_hosting', keywords=r'размещение сайта|адрес расположения серверов|адрес.*сервер.*сайт'),
_f('Адрес хостинга', 'site_hosting_address', keywords=r'адрес.*хостинг|адрес.*располож.*сервер'),
_f('Электронная почта', 'email', keywords=r'электронная почта'),
_f('Телефон', 'phone', keywords=r'телефон'),
],
}
# ============================================================
# ЗАГРУЗКА НАСТРОЕК
# ============================================================
def load_settings(path=None):
"""Загрузка словаря импорта из JSON-файла.
path=None -> файл по умолчанию рядом с программой.
Если файла нет или он битый — возвращается встроенный дефолт.
"""
if path is None:
path = default_import_settings_path()
if not path or not os.path.exists(path):
return DEFAULT_SETTINGS
try:
with open(path, encoding='utf-8-sig') as f:
loaded = json.load(f)
# Дополняем недостающие ключи из дефолта
merged = dict(DEFAULT_SETTINGS)
for k, v in loaded.items():
if k == 'fields' and isinstance(v, list):
merged['fields'] = v
else:
merged[k] = v
return merged
except Exception:
return DEFAULT_SETTINGS
def ensure_default_file(path=None):
"""Создаёт файл настроек import_settings.json, если его ещё нет.
Возвращает путь к файлу. Нужно, чтобы пользователь видел словарь
импорта и мог править его в блокноте без пересборки программы.
"""
if path is None:
path = default_import_settings_path()
if os.path.exists(path):
return path
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump(DEFAULT_SETTINGS, f, ensure_ascii=False, indent=2)
except Exception:
pass
return path
def parse_cell_ref(cell_ref):
"""Разбор ссылки на ячейку: 'Общие сведения'!B2 / Общие сведения!B2 / B2.
Возвращает (sheet_name или None, координаты 'B2').
"""
if not cell_ref:
return None, ''
ref = cell_ref.strip()
if ref.startswith('='):
ref = ref[1:].strip()
if '!' in ref:
sheet_part, coord = ref.split('!', 1)
sheet_part = sheet_part.strip()
if sheet_part.startswith("'") and sheet_part.endswith("'"):
sheet_part = sheet_part[1:-1]
return sheet_part or None, coord.strip().upper()
return None, ref.upper()
def cell_value(ws, coord):
"""Значение ячейки по координате 'B2' (с нормализацией)."""
if not coord:
return ''
try:
val = ws[coord]
return val.value if val is not None else ''
except Exception:
return ''
+560
View File
@@ -0,0 +1,560 @@
# -*- 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
from .import_settings import load_settings, parse_cell_ref, cell_value
# Известные аббревиатуры, которые сохраняются КАПСОМ при нормализации
_CAPS_ABBR = {
'АРМ', 'СКУД', 'СЗИ', 'СКЗИ', 'СОВ', 'ЛВС', 'ИНН', 'КПП', 'ОГРН', 'ЦОД',
'МБУ', 'МКУ', 'ООО', 'СНИЛС', 'СБИС', 'ЕМСЭД', 'ФИО', 'ИСПДН', 'ККТ',
}
def _normalize_caps(text: str) -> str:
"""Приводит КАПС-текст к нормальному регистру по словам.
Сохраняет:
- аббревиатуры из белого списка (АРМ, СКУД, ООО, ИНН и т.п.)
- инициалы и короткие служебные слова (И., Г., УЛ., №1)
- числа и номера (350000, №1)
- слова, уже содержащие строчные буквы
Длинные слова целиком заглавными (ДИРЕКТОР, КРАСНОДАР, ПЕТРОВ) → «Директор», «Краснодар».
"""
words = text.split()
result = []
for w in words:
# отделяем пунктуацию по краям слова
pre = ''
post = ''
core = w
while core and not core[0].isalnum():
pre += core[0]
core = core[1:]
while core and not core[-1].isalnum():
post = core[-1] + post
core = core[:-1]
if not core:
result.append(w)
continue
# в слове уже есть строчные буквы — не трогаем
if any('а' <= c <= 'я' or c == 'ё' for c in core):
result.append(w)
continue
# нет заглавных кириллических букв (числа, латиница и т.п.) — не трогаем
if not any('А' <= c <= 'Я' or c == 'Ё' for c in core):
result.append(w)
continue
letters = [c for c in core if c.isalpha()]
# инициалы и служебные слова (1-2 буквы: И., Г., УЛ., ООО — но ООО в белом списке)
if len(letters) <= 2:
result.append(w)
continue
# известная аббревиатура — сохраняем как есть
if core.upper() in _CAPS_ABBR:
result.append(w)
continue
# обычное длинное слово: первая заглавная, остальные строчные
normalized = core[0].upper() + core[1:].lower()
result.append(pre + normalized + post)
return ' '.join(result)
_ADDRESS_ABBR = {
# нормальные сокращения для адресов: «Г» → «г.», «Р-Н» → «р-н.» и т.п.
'Г': 'г.', 'УЛ': 'ул.', 'Д': 'д.', 'КВ': 'кв.', 'ПР': 'пр.',
'ПЕР': 'пер.', 'ПЛ': 'пл.', 'ОБЛ': 'обл.', 'ПОС': 'пос.',
'СТ': 'ст.', 'Ш': 'ш.', 'Р-Н': 'р-н.', 'Б-Р': 'б-р.', 'МКР': 'мкр.',
'НАБ': 'наб.', 'ТУП': 'туп.', 'ПР-Д': 'пр-д.', 'КРП': 'крп.',
'Г.': 'г.', 'УЛ.': 'ул.', 'Д.': 'д.', 'Р-Н.': 'р-н.',
}
def normalize_address(text: str) -> str:
"""Приводит адрес к нормальному виду: «Г Крымск» → «г. Крымск»,
«Р-Н Крымский» → «р-н. Крымский», «УЛ Карла» → «ул. Карла»."""
if not text:
return ''
parts = re.split(r'(\s+|[,\;])', text)
out = []
for p in parts:
if p.strip() and p.strip().upper() in _ADDRESS_ABBR:
out.append(_ADDRESS_ABBR[p.strip().upper()])
else:
out.append(p)
return ''.join(out)
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) > 2 and any('А' <= c <= 'Я' or c == 'Ё' for c in text):
has_lower = any('а' <= c <= 'я' or c == 'ё' for c in text)
if not has_lower:
text = _normalize_caps(text)
return text
def import_152fz(filepath: str, log_fn: Optional[Callable] = None, settings_path: Optional[str] = None) -> dict:
"""Импорт данных из опросного листа Excel (152-ФЗ).
settings_path — путь к JSON-файлу настроек импорта (что и откуда брать).
Если None — файл import_settings.json рядом с программой; если его нет —
используется встроенный словарь по умолчанию.
"""
if openpyxl is None:
raise ImportError("openpyxl не установлен. Установите: pip install openpyxl")
if log_fn is None:
log_fn = lambda msg: None
settings = load_settings(settings_path)
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, settings, log_fn)
_import_152_is_list(wb, data, settings, log_fn)
_import_152_employees(wb, data, settings, 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]
# Нормализация адресов: «Г Крымск» → «г. Крымск», «Р-Н» → «р-н.» и т.п.
for key in ('addressLegal', 'addressActual'):
if data.get(key):
data[key] = normalize_address(data[key])
_validate_company_data(data, log_fn)
return data
def _import_152_common(wb, data, settings, log_fn):
"""Импорт общих сведений по словарю настроек.
Для каждого поля: сначала пробуем конкретную ячейку (cell),
если она задана и заполнена; иначе ищем строку по ключевым
словам (keywords) в колонке A листа «Общие сведения».
"""
sheet_name = None
sheet_kw = settings.get('common_sheet_keyword', 'общие')
for sn in wb.sheetnames:
if sheet_kw in sn.lower():
sheet_name = sn
break
if not sheet_name:
log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден")
return
ws = wb[sheet_name]
value_col = int(settings.get('value_column', 1))
# Кэш значений: для каждого поля ищем по строке один раз
row_cache = {}
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 ''
if not field_raw:
continue
row_cache[field_raw.lower()] = (
clean_value(row[value_col]) if len(row) > value_col else ''
)
for rule in settings.get('fields', []):
key = rule.get('var', '')
if not key:
continue
name = rule.get('name', key)
cell_ref = rule.get('cell', '')
keywords = rule.get('keywords', '')
# 1) Конкретная ячейка
if cell_ref:
sheet_part, coord = parse_cell_ref(cell_ref)
target_ws = ws
if sheet_part:
for sn in wb.sheetnames:
if sn.lower() == sheet_part.lower():
target_ws = wb[sn]
break
val = clean_value(cell_value(target_ws, coord))
if val:
data[key] = val
log_fn(f" {name} [ячейка {coord}]: {val}")
continue
# 2) Поиск по ключевым словам
if keywords:
try:
rx = re.compile(keywords, re.IGNORECASE)
except re.error:
rx = None
if rx:
for field_lower, val in row_cache.items():
if rx.search(field_lower):
if val:
data[key] = val
log_fn(f" {name} [по ключу]: {val}")
break
def _import_152_is_list(wb, data, settings, log_fn):
"""Импорт информационных систем из остальных листов."""
exclude_kws = settings.get('is_sheet_exclude_keywords', ['общие сведения', 'сотрудники'])
is_sheets = []
for sn in wb.sheetnames:
sn_lower = sn.lower()
if any(kw in sn_lower for kw in exclude_kws):
continue
is_sheets.append(sn)
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['structure'] = val_b
elif ':' in field:
# Значение может быть записано в той же ячейке после двоеточия
tail = field.split(':', 1)[1].strip()
if tail and 'выбрать' not in tail.lower():
is_obj['structure'] = tail
continue
if 'названия программ' in field_lower or ('программ' in field_lower and 'ос взаимодейств' in field_lower):
if val_b:
is_obj['software'] = val_b
continue
if 'локальную сеть' in field_lower:
is_obj['is_has_lan'] = is_checked
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 or 'описание информационной' in field_lower:
if val_b:
is_obj['description'] = val_b
log_fn(f" Описание: {val_b[:60]}")
continue
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 = val_b or field or ''
if purpose_val and 'выбрать' not in purpose_val.lower() and ':' not in purpose_val[:40]:
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:
# Без префикса категории: просто название средства (Kaspersky, КриптоПро)
is_obj['defense_tools'].append(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, settings, log_fn):
"""Импорт комиссии и сотрудников из листа «Сотрудники»."""
sheet_name = None
sheet_kw = settings.get('employees_sheet_keyword', 'сотруд')
for sn in wb.sheetnames:
if sheet_kw in sn.lower():
sheet_name = sn
break
if not sheet_name:
log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден")
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 = 'Член комиссии'
# Роль ищем в колонках E (было) и D (Примечание — новый формат)
for col in (col_e, col_d):
if col:
col_lower = col.lower()
for keyword, role_name in COMMISSION_ROLES.items():
if keyword in col_lower:
role = role_name
break
if role != 'Член комиссии':
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}»")
+10 -1
View File
@@ -18,7 +18,6 @@ class InformationSystem:
"""Информационная система (обработка ПДн)"""
name: str = ""
description: str = ""
software: str = ""
is_local_network: bool = False
is_internet: bool = False
defence_level: str = ""
@@ -29,12 +28,16 @@ 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)
personal_data_category: List[str] = field(default_factory=list)
purpose: str = ""
room: str = ""
structure: str = "" # Структура информационной системы
software: str = "" # Программное обеспечение
@dataclass
@@ -89,6 +92,10 @@ 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)
# Наименование структурного подразделения по безопасности
security_department: str = ""
def update_declensions(self, inflect_func):
"""Обновить склонения полного названия"""
@@ -133,6 +140,8 @@ class Company:
PaperDocument(**p) if isinstance(p, dict) else p
for p in d["paper_documents_list"]
]
if "employees_access" in d and not isinstance(d["employees_access"], list):
d["employees_access"] = []
valid = {f.name for f in cls.__dataclass_fields__.values()}
return cls(**{k: v for k, v in d.items() if k in valid})
+2261
View File
File diff suppressed because it is too large Load Diff
+209 -15
View File
@@ -9,6 +9,12 @@ from . import declension as dc
from .models import Company, CommissionMember
def _norm_name(s):
"""Нормализация ФИО для сравнения: регистр, пробелы, точки, тире игнорируются."""
s = (s or '').lower().replace('ё', 'е')
return re.sub(r'[\s.,;:()«»"\'\-]+', '', s)
# ============================================================
# ОПИСАНИЕ ПЕРЕМЕННЫХ
# ============================================================
@@ -50,6 +56,8 @@ VARIABLE_DEFS = [
('{{company_ogrn}}', 'A', 'ОГРН', 'company:ogrn'),
('{{company_ogrn_date}}', 'A', 'Дата ОГРН', 'company:ogrn_date'),
('{{company_kpp}}', 'A', 'КПП', 'company:kpp'),
('{{security_department}}', 'A', 'Наименование структурного подразделения по безопасности', 'company:security_department'),
('{{item.pd_actions}}', 'B', 'Действия с ПДн (в цикле ИС)', 'item:pd_actions'),
# === Комиссия ===
('{{commission_list}}', 'A', 'Список комиссии', 'commission_list'),
@@ -62,6 +70,28 @@ 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'),
]
# Маппинг старых переменных <...> → {{...}}
@@ -143,14 +173,42 @@ def build_replacements(
return replacements
def _commission_role_display(members) -> List[str]:
"""Группирует роли комиссии для вывода:
- Председатель/Секретарь/Заместитель отдельной строкой со своей ролью;
- обычные члены первый получает «Члены комиссии», остальные пустую роль
(чтобы не повторять «Член комиссии» в каждой строке)."""
result = []
member_used = False
for m in members:
role = (m.role or 'член комиссии').strip()
role_lower = role.lower()
if ('председател' in role_lower or 'секретар' in role_lower
or 'заместител' in role_lower or 'зам. председателя' in role_lower):
result.append(role)
continue
# обычный член комиссии
if not member_used:
result.append('Члены комиссии')
member_used = True
else:
result.append('')
return result
def _build_commission_data(company: Company) -> Dict[str, str]:
data = {}
members = company.commission or []
display_roles = _commission_role_display(members)
lines = []
for i, m in enumerate(members, 1):
role = m.role or 'член комиссии'
line = f"{i}. {role}: {m.position}{m.fio}" if m.position else f"{i}. {role}: {m.fio}"
for i, (m, role_disp) in enumerate(zip(members, display_roles), 1):
if role_disp:
line = (f"{i}. {role_disp}: {m.position}{m.fio}" if m.position
else f"{i}. {role_disp}: {m.fio}")
else:
line = (f"{i}. {m.position}{m.fio}" if m.position
else f"{i}. {m.fio}")
lines.append(line)
data['commission_list'] = '\n'.join(lines) if lines else ''
@@ -164,12 +222,12 @@ def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int):
'is_description': isys.description or '',
'is_has_internet': 'да' if isys.is_internet else 'нет',
'is_defence_level': str(isys.defence_level or ''),
'is_category': isys.category or '',
'is_category': ', '.join(isys.personal_data_category) or isys.category or '',
'is_pd_list': ', '.join(isys.personal_data_list or []) or '',
'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 '',
'is_structure': getattr(isys, 'structure', '') or '',
}
for f, v in fields.items():
replacements[f'{{{{{f}_{idx}}}}}'] = str(v)
@@ -181,13 +239,39 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
# Комиссия
members = company.commission or []
if members:
display_roles = _commission_role_display(members)
items = []
for m in members:
for m, role_disp in zip(members, display_roles):
pos = m.position or ''
fio = m.fio or ''
if role_disp:
full = f"{role_disp} {pos}{fio}" if pos != '' else f"{role_disp}{fio}"
else:
full = f"{pos}{fio}" if pos != '' else fio
# Строка подписи: «___________ / » (только подчёркивание и слэш).
# Роль НЕ включаем — в шаблонах она выводится отдельной колонкой {{item.role}},
# иначе получается дубль «Председатель комиссии | Председатель комиссии: ___».
if 'Председатель' in role_disp:
sig = f"{'_' * 11} / "
sig_full = f"{role_disp}: {'_' * 11} / "
elif role_disp:
sig = f"{'_' * 17} / "
sig_full = f"{role_disp}: {'_' * 17} / "
else:
sig = f"{'_' * 17} / "
sig_full = f"{'_' * 17} / "
initials = dc.get_initials(fio) if fio != '' else ''
items.append({
'role': m.role or 'член комиссии',
'position': m.position or '',
'fio': m.fio or '',
'full': f"{m.role} {m.position}{m.fio}" if m.position else f"{m.role}{m.fio}",
'role': role_disp,
'position': pos,
'fio': fio,
'fio_initials': dc.get_initials(fio) if fio != '' else '',
'fio_short': dc.get_short_fio(fio) if fio != '' else '',
'full': full,
# Рамка подписи без роли и ФИО: «___________ / » (ФИО добавится)
'signature': sig,
# Полная подпись с ролью и ФИО: «Роль: ___________ / И.О. Фамилия»
'signature_full': (sig_full + initials).strip(),
})
loops['commission'] = items
@@ -195,33 +279,81 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
if is_list:
items = []
for isys in is_list:
# Пользователи ИС для вложенного цикла <!-- loop:users_loop -->
# ВСЕ пользователи, включая администратора (без дублей по ФИО)
seen = set()
users_loop = []
for u in (isys.users_list or []):
key = _norm_name(u)
if key and key in seen:
continue
seen.add(key)
users_loop.append({'fio': u, 'position': ''})
items.append({
'name': isys.name or '',
'description': isys.description or '',
'software': isys.software or '',
'address': getattr(isys, 'address', '') or '',
'is_has_lan': 'Да' if isys.is_local_network else 'Нет',
'has_internet': 'да' if isys.is_internet else 'нет',
'defence_level': str(isys.defence_level or ''),
'threat_type': str(isys.threat_type or '3'),
'category': isys.category or '',
'category': ', '.join(isys.personal_data_category) or isys.category or '',
'pd_list': ', '.join(isys.personal_data_list or []) or '',
'pd_count': str(isys.pd_count or ''),
'users': ', '.join(isys.users_list or []) or '',
'defense_tools_list': '; '.join(isys.defense_tools_list or []) or '',
'users_loop': users_loop,
'defense_tools_list': ', '.join(isys.defense_tools_list or []) or '',
'processing_modes': isys.processing_modes or '',
'pd_subjects': '; '.join(isys.pd_subjects_list or []) or '',
'is_purpose': isys.purpose or '',
'structure': getattr(isys, 'structure', '') or '',
'software': getattr(isys, 'software', '') or '',
'pd_actions': '; '.join(isys.pd_actions_list or []) or '',
})
loops['information_systems'] = items
# Бумажные документы
def _storage_with_prep(storage: str) -> str:
"""«сейф» → «в сейфе» (предлог + предложный падеж).
Если уже начинается с предлога оставить как есть."""
if not storage:
return ''
s = storage.strip()
low = s.lower()
if low.startswith(('в ', 'на ', 'под ', 'за ', 'у ', 'при ', 'из ', 'со ', 'во ', 'над ', 'перед ')):
return s
words = s.split()
out = []
# Исключения предложного падежа: «в шкафу», «в углу» (не «в шкафе»)
loct_exceptions = {'шкаф': 'шкафу', 'угол': 'углу', 'край': 'краю', 'рот': 'рту', 'мост': 'мосту'}
for w in words:
core = w.strip('.,;')
if not core or core.isdigit() or core.startswith(''):
out.append(w)
continue
if core.lower() in loct_exceptions:
out.append(loct_exceptions[core.lower()])
continue
try:
p = dc.morph.parse(core)[0]
inf = p.inflect({'loct'})
if inf:
out.append(inf.word)
continue
except Exception:
pass
out.append(w)
return 'в ' + ' '.join(out)
paper_list = getattr(company, 'paper_documents_list', None) or []
if paper_list:
items = []
for p in paper_list:
storage = p.storage or ''
items.append({
'document': p.name or '',
'storage': p.storage or '',
'storage': _storage_with_prep(storage), # «в сейфе»
'storage_raw': storage, # «сейф» (как введено)
})
loops['paper_documents'] = items
@@ -260,7 +392,21 @@ def _resolve(source: str, ctx: Dict) -> str:
if source.startswith('decl:'):
_, field, case = source.split(':')
name = getattr(company, field, '') or ''
return dc.company_name_decline(name, case) if name else ''
case_gr = dc._normalize_case(case)
# Сначала берём ГОТОВОЕ склонение из модели (пользователь мог
# подставить через Морфер/ИИ или ввести вручную), пересчитываем
# только если поле пустое
case_idx = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}.get(case_gr)
if case_idx:
if field == 'full_name':
ready = getattr(company, f'company_name_{case_idx}', '') or ''
if ready:
return ready
elif field == 'short_name':
ready = getattr(company, f'short_name_{case_idx}', '') or ''
if ready:
return ready
return dc.company_name_decline(name, case_gr) if name else ''
# Форматы ФИО
if source.startswith('short_fio:'):
@@ -293,6 +439,54 @@ 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 ''
+440
View File
@@ -0,0 +1,440 @@
# -*- coding: utf-8 -*-
"""DokoGen — проверка данных через внешние сервисы.
Провайдеры:
- Морфер 3.0 (morpher.ru) склонение ФИО/названий по падежам. Бесплатно, без ключа.
- DaData (dadata.ru) проверка организации по ИНН/ОГРН (наименование, адрес, КПП).
Нужен API-ключ (бесплатный тариф ~10 000 запросов/день).
- ИИ (OpenAI-совместимый) ИИ-проверка склонений и реквизитов (DeepSeek, ChatGPT и др.).
Нужен API-ключ и модель. Данные ИИ ВСЕГДА помечаются дисклеймером.
Каждая функция возвращает (result, error):
- result: dict с полями {source, source_url, data, disclaimer, apply}
- error: строка ошибки или None
"""
import json
import re
from datetime import datetime, timedelta
import urllib.parse
import urllib.request
import urllib.error
MORPHER_URL = "https://ws3.morpher.ru/russian/declension"
DADATA_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party"
DEFAULT_AI_URL = "https://api.deepseek.com/chat/completions"
CASES = ['И', 'Р', 'Д', 'В', 'Т', 'П']
CASE_NAMES = {
'И': 'Именительный', 'Р': 'Родительный', 'Д': 'Дательный',
'В': 'Винительный', 'Т': 'Творительный', 'П': 'Предложный',
}
def _http_json(url, data=None, headers=None, timeout=30):
"""HTTP-запрос, возвращает (json, error_str)."""
body = None
if data is not None:
body = json.dumps(data).encode('utf-8')
req = urllib.request.Request(url, data=body, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode('utf-8')
return json.loads(raw), None
except urllib.error.HTTPError as e:
try:
err_body = e.read().decode('utf-8')
except Exception:
err_body = ''
return None, f"HTTP {e.code}: {err_body[:300]}"
except Exception as e:
return None, str(e)
# ============================================================
# НОРМАЛИЗАЦИЯ ТЕКСТА (КАПС → нормальный регистр, сокращения адреса)
# ============================================================
_CAPS_ABBR = {
'АРМ', 'СКУД', 'СЗИ', 'СКЗИ', 'СОВ', 'ЛВС', 'ИНН', 'КПП', 'ОГРН', 'ЦОД',
'МБУ', 'МКУ', 'ООО', 'СНИЛС', 'СБИС', 'ЕМСЭД', 'ФИО', 'ИСПДН', 'ККТ',
}
_ADDRESS_ABBR = {
'Г': 'г.', 'УЛ': 'ул.', 'Д': 'д.', 'КВ': 'кв.', 'ПР': 'пр.',
'ПЕР': 'пер.', 'ПЛ': 'пл.', 'ОБЛ': 'обл.', 'ПОС': 'пос.',
'СТ': 'ст.', 'Ш': 'ш.', 'Р-Н': 'р-н.', 'Б-Р': 'б-р.', 'МКР': 'мкр.',
'НАБ': 'наб.', 'ТУП': 'туп.', 'ПР-Д': 'пр-д.', 'КРП': 'крп.',
'Г.': 'г.', 'УЛ.': 'ул.', 'Д.': 'д.', 'Р-Н.': 'р-н.',
}
def _normalize_text(text):
"""КАПС → нормальный регистр по словам (сохраняя аббревиатуры и числа)."""
if not text:
return ''
words = str(text).split()
out = []
for w in words:
pre = ''
post = ''
core = w
while core and not core[0].isalnum():
pre += core[0]
core = core[1:]
while core and not core[-1].isalnum():
post = core[-1] + post
core = core[:-1]
if not core:
out.append(w)
continue
if any('а' <= c <= 'я' or c == 'ё' for c in core):
out.append(w)
continue
if not any('А' <= c <= 'Я' or c == 'Ё' for c in core):
out.append(w)
continue
letters = [c for c in core if c.isalpha()]
if len(letters) <= 2:
out.append(w)
continue
if core.upper() in _CAPS_ABBR:
out.append(w)
continue
out.append(pre + core[0].upper() + core[1:].lower() + post)
return ' '.join(out)
def normalize_address(text):
"""«Г Крымск» → «г. Крымск», «Р-Н» → «р-н.» и т.п."""
if not text:
return ''
parts = re.split(r'(\s+|[,\;])', text)
out = []
for p in parts:
if p.strip() and p.strip().upper() in _ADDRESS_ABBR:
out.append(_ADDRESS_ABBR[p.strip().upper()])
else:
out.append(p)
return ''.join(out)
def _fmt_date(value):
"""Дата: '2005-02-09''09.02.2005'; миллисекунды 1107907200000 → дата."""
if not value:
return ''
# миллисекунды с эпохи (DaData иногда отдаёт так)
if isinstance(value, (int, float)) and value > 10 ** 10:
try:
dt = datetime(1970, 1, 1) + timedelta(milliseconds=value)
return dt.strftime('%d.%m.%Y')
except Exception:
pass
s = str(value).strip()
parts = s.split('-')
if len(parts) == 3 and all(p.isdigit() for p in parts):
return f"{parts[2]}.{parts[1]}.{parts[0]}"
return s
# ============================================================
# МОРФЕР 3.0 — склонение (бесплатно, без ключа)
# ============================================================
def check_declension_morpher(text):
"""Склонение слова/ФИО/названия через Морфер 3.0."""
if not text or not text.strip():
return None, "Пустое значение для склонения"
url = MORPHER_URL + '?' + urllib.parse.urlencode({'s': text.strip(), 'format': 'json'})
js, err = _http_json(url)
if err:
return None, err
if not isinstance(js, dict):
return None, "Неожиданный ответ Морфера"
decl = {}
for c in CASES:
val = js.get(c) or js.get(c.lower()) or ''
if isinstance(val, str) and val:
decl[c] = val
if not decl:
return None, "Морфер не смог просклонять (возможно, это не слово/ФИО)"
# Морфер не возвращает именительный падеж (он равен исходному слову) —
# добавляем его сами, иначе поле «Именительный» остаётся пустым
if 'И' not in decl:
decl['И'] = text.strip()
return {
'source': 'Морфер 3.0',
'source_url': 'https://morpher.ru',
'disclaimer': '',
'kind': 'declension',
'original': text.strip(),
'data': decl,
'apply': {'declension': decl},
}, None
# ============================================================
# DADATA — проверка организации по ИНН (нужен API-ключ)
# ============================================================
def check_company_dadata(inn, token):
"""Проверка организации по ИНН/ОГРН через DaData.
Данные нормализуются: регистр (КАПС обычный), адрес (г., ул., д.),
дата ОГРН (в т.ч. из миллисекунд).
"""
if not token:
return None, "Не задан API-ключ DaData (Файл → Настройки → API)"
inn = (inn or '').replace(' ', '')
if not inn or not inn.isdigit():
return None, "Введите ИНН или ОГРН для проверки"
headers = {
'Content-Type': 'application/json',
'Authorization': f'Token {token}',
}
js, err = _http_json(DADATA_URL, data={'query': inn}, headers=headers)
if err:
return None, err
suggestions = (js or {}).get('suggestions') or []
if not suggestions:
return None, f"DaData: организация с ИНН/ОГРН {inn} не найдена"
s = suggestions[0]
d = s.get('data') or {}
name = d.get('name') or {}
address = d.get('address') or {}
management = d.get('management') or {}
full_name = _normalize_text(name.get('full_with_opf') or s.get('value', ''))
short_name = _normalize_text(name.get('short_with_opf') or name.get('short') or '')
addr = normalize_address(address.get('value', ''))
# DaData отдаёт индекс отдельным полем postal_code — добавляем его
# в начало адреса, если его там ещё нет
postal = (address.get('data') or {}).get('postal_code') or address.get('postal_code') or ''
postal = str(postal).strip()
if postal and postal.isdigit():
addr_first = (addr or '').split(',')[0].strip()
if not addr_first.startswith(postal):
addr = f"{postal}, {addr}" if addr else postal
chief_position = _normalize_text(management.get('post', ''))
chief_fio = _normalize_text(management.get('name', ''))
ogrn_date = _fmt_date(d.get('ogrn_date') or '')
return {
'source': 'DaData',
'source_url': 'https://dadata.ru',
'disclaimer': '',
'kind': 'company',
'query': inn,
'data': {
'full_name': full_name,
'short_name': short_name,
'address': addr,
'kpp': d.get('kpp', ''),
'ogrn': d.get('ogrn', ''),
'ogrn_date': ogrn_date,
'chief_position': chief_position,
'chief_fio': chief_fio,
'inn': d.get('inn', inn),
},
'apply': {'company': {
'full_name': full_name,
'short_name': short_name,
'address': addr,
'kpp': d.get('kpp', ''),
'ogrn': d.get('ogrn', ''),
'ogrn_date': ogrn_date,
'chief_position': chief_position,
'chief_fio': chief_fio,
}},
}, None
# ============================================================
# ИИ (OpenAI-совместимый: DeepSeek, ChatGPT и др.)
# ============================================================
AI_DISCLAIMER = ("⚠ Сгенерировано ИИ. Информацию рекомендуется перепроверить "
"по официальным источникам.")
def _ai_json(api_key, model, base_url, system, user, timeout=60):
"""Запрос к OpenAI-совместимому API (DeepSeek, ChatGPT и т.п.)."""
if not api_key:
return None, "Не задан API-ключ ИИ (Файл → Настройки → API)"
url = (base_url or DEFAULT_AI_URL).rstrip('/')
if not url.endswith('/chat/completions'):
url += '/chat/completions'
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}',
}
payload = {
'model': model or 'deepseek-chat',
'messages': [
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
'temperature': 0.2,
'response_format': {'type': 'json_object'},
}
js, err = _http_json(url, data=payload, headers=headers, timeout=timeout)
if err:
return None, err
try:
content = js['choices'][0]['message']['content']
return json.loads(content), None
except Exception:
return None, "ИИ вернул неожиданный ответ"
def check_declension_ai(text, api_key, model='deepseek-chat', base_url=None):
"""ИИ-рецензия склонений: проверяет падежи и предлагает исправления."""
if not text or not text.strip():
return None, "Пустое значение для склонения"
system = (
"Ты — эксперт по русскому языку. Склоняй слово/ФИО/название организации "
"по падежам. Верни ТОЛЬКО JSON: "
'{"И": "...", "Р": "...", "Д": "...", "В": "...", "Т": "...", "П": "...", "комментарий": "..."}'
)
user = f"Просклоняй: {text.strip()}"
js, err = _ai_json(api_key, model, base_url, system, user)
if err:
return None, err
decl = {}
for c in CASES:
val = js.get(c) or js.get(c.lower()) or ''
if isinstance(val, str) and val:
decl[c] = val
comment = js.get('комментарий', '') or ''
if not decl:
return None, "ИИ не смог просклонять"
# Именительный падеж равен исходному слову — подставляем, если ИИ его не вернул
if 'И' not in decl:
decl['И'] = text.strip()
return {
'source': 'ИИ',
'source_url': 'openai-совместимый API',
'disclaimer': AI_DISCLAIMER,
'kind': 'declension',
'original': text.strip(),
'data': decl,
'comment': comment,
'apply': {'declension': decl},
}, None
# Поля организации для ИИ-проверки (для читаемого вывода)
COMPANY_FIELDS = [
('full_name', 'Полное наименование'),
('short_name', 'Краткое наименование'),
('address', 'Юридический адрес'),
('inn', 'ИНН'),
('kpp', 'КПП'),
('ogrn', 'ОГРН'),
('ogrn_date', 'Дата ОГРН'),
('chief_position', 'Руководитель (должность)'),
('chief_fio', 'Руководитель (ФИО)'),
]
# Поля для сравнения: (ключ, подпись, тип)
# type: text — регистр/пунктуация игнорируются; digits — любое отличие = расхождение
_COMPARE_FIELDS = [
('full_name', 'Наименование', 'text'),
('short_name', 'Краткое наименование', 'text'),
('address', 'Юридический адрес', 'text'),
('inn', 'ИНН', 'digits'),
('kpp', 'КПП', 'digits'),
('ogrn', 'ОГРН', 'digits'),
('ogrn_date', 'Дата ОГРН', 'text'),
('chief_position', 'Руководитель (должность)', 'text'),
('chief_fio', 'Руководитель (ФИО)', 'text'),
]
def _norm_compare(s):
"""Нормализация для сравнения: регистр, ё→е, пробелы/точки/дефисы/кавычки убираются."""
s = (s or '').lower().replace('ё', 'е')
s = re.sub(r'[\s.,;:()«»"\'\-—–]+', '', s)
return s
def _norm_digits(s):
"""Только цифры."""
return re.sub(r'\D', '', s or '')
def compare_company_values(old_data, new_data):
"""Сравнивает старые и новые значения реквизитов организации.
Возвращает список расхождений: [(подпись, было, стало), ...].
- text: разница в регистре/пунктуации (ИВАНОВ И.И. vs Иванов И.И.) НЕ считается;
- digits (ИНН/КПП/ОГРН): любое отличие цифр = расхождение.
"""
diffs = []
for key, label, ftype in _COMPARE_FIELDS:
old = (old_data or {}).get(key) or ''
new = (new_data or {}).get(key) or ''
if not old or not new:
continue
if ftype == 'digits':
if _norm_digits(old) != _norm_digits(new):
diffs.append((label, old, new))
else:
if _norm_compare(old) != _norm_compare(new):
diffs.append((label, old, new))
return diffs
def check_company_ai(company_data, api_key, model='deepseek-chat', base_url=None):
"""ИИ-проверка реквизитов организации.
Возвращает оценку, замечания, предложения и полный «снимок» данных
(что ИИ увидел и как оценил каждое поле) чтобы пользователь видел
не только «ОК», но и информацию по организации.
"""
if not api_key:
return None, "Не задан API-ключ ИИ (Файл → Настройки → API)"
system = (
"Ты — юрист по защите персональных данных (152-ФЗ). Проверь реквизиты организации. "
"Верни ТОЛЬКО JSON: "
'{"оценка": "ok|warning|error", '
'"замечания": ["..."], '
'"предложения": {"full_name": "...", "short_name": "...", "address": "...", '
'"inn": "...", "kpp": "...", "ogrn": "...", "ogrn_date": "...", '
'"chief_position": "...", "chief_fio": "..."}, '
'"поля": {"full_name": {"статус": "ok|пусто|ошибка", "комментарий": "..."}, ...}} '
"В «предложениях» указывай исправленные значения только если уверен, иначе пустые строки. "
"В «полях» дай оценку каждого поля (ok/пусто/ошибка) и комментарий."
)
user = json.dumps(company_data, ensure_ascii=False)
js, err = _ai_json(api_key, model, base_url, system, user)
if err:
return None, err
notes = js.get('замечания') or []
if isinstance(notes, str):
notes = [notes]
proposals = js.get('предложения') or {}
if not isinstance(proposals, dict):
proposals = {}
fields = js.get('поля') or {}
if not isinstance(fields, dict):
fields = {}
return {
'source': 'ИИ',
'source_url': 'openai-совместимый API',
'disclaimer': AI_DISCLAIMER,
'kind': 'company',
'data': {
'rating': js.get('оценка', 'warning'),
'notes': notes,
'proposals': proposals,
'fields': fields,
},
'apply': {'company': {k: v for k, v in proposals.items() if v}},
}, None
-378
View File
@@ -1,378 +0,0 @@
# -*- 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)
+1 -5
View File
@@ -1,15 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""DokoGen — Генератор документов 152-ФЗ
Точка входа: python3 main.py
Точка входа: python 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__":
+199
View File
@@ -0,0 +1,199 @@
#!/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()
-1052
View File
File diff suppressed because it is too large Load Diff