fix: _log_unmatched + unpack 4→3 в ui.py

This commit is contained in:
2026-07-30 17:32:45 +04:00
commit c575dec358
15 changed files with 3288 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""DokoGen — Пакет генератора документов 152-ФЗ"""
from .models import Company, CommissionMember, InformationSystem, PaperDocument
from .declension import inflect_name, decline, company_name_decline, get_short_fio, get_initials
from .variables import build_replacements, VARIABLE_DEFS
from .generator import process_template
# Импорт из Excel
from . import importer
__version__ = "1.0.0"
__app_name__ = "ДоКоГеНеРаТоР"
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""DokoGen — Точка входа"""
import sys
import os
# Добавляем путь к пакету
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ui import main
if __name__ == "__main__":
main()
+222
View File
@@ -0,0 +1,222 @@
# -*- coding: utf-8 -*-
"""DokoGen — Склонение (pymorphy3 + правила для ФИО/организаций)"""
from functools import lru_cache
from typing import Dict
import re
try:
import pymorphy3
morph = pymorphy3.MorphAnalyzer()
MORPH_AVAILABLE = True
except ImportError:
morph = None
MORPH_AVAILABLE = False
CASES = ["nomn", "gent", "datv", "accs", "ablt", "loct"]
CASE_MAP = {
"nominative": "nomn", "им": "nomn",
"genitive": "gent", "род": "gent",
"dative": "datv", "дат": "datv",
"accusative": "accs", "вин": "accs",
"instrumental": "ablt", "твор": "ablt",
"prepositional": "loct", "пр": "loct",
}
CASE_NAMES = {
"nomn": "Именительный (Кто? Что?)",
"gent": "Родительный (Кого? Чего?)",
"datv": "Дательный (Кому? Чему?)",
"accs": "Винительный (Кого? Что?)",
"ablt": "Творительный (Кем? Чем?)",
"loct": "Предложный (О ком? О чём?)",
}
CASE_SHORT = {
"nomn": "им.п.", "gent": "род.п.", "datv": "дат.п.",
"accs": "вин.п.", "ablt": "твор.п.", "loct": "пр.п.",
}
def _normalize_case(case: str) -> str:
return CASE_MAP.get(case, "nomn" if case not in CASES else case)
def inflect_name(name: str) -> Dict[str, str]:
"""Склонение названия организации по всем падежам."""
return {gr: company_name_decline(name, gr) for gr in CASES}
@lru_cache(maxsize=512)
def company_name_decline(name: str, case: str) -> str:
"""Склонение названия организации."""
if not name:
return ""
case_gr = _normalize_case(case)
if case_gr == "nomn":
return name
m = re.match(r'^(.+?)\s*([\u00ab\"].*[\u00bb\"])$', name.strip())
if m:
prefix = m.group(1).strip()
quoted = m.group(2)
if prefix:
return decline(prefix, case_gr, decline_all=True) + " " + quoted
return name
return decline(name, case_gr, decline_all=True)
@lru_cache(maxsize=512)
def decline(text: str, case: str, decline_all: bool = False) -> str:
"""Склонение строки (ФИО, фразы) по падежам."""
if not text:
return ""
case_gr = _normalize_case(case)
if case_gr == "nomn":
return text
clean_text, quoted = _extract_quoted(text)
tokens = re.split(r"(\s+)", clean_text)
content_tokens = [t for t in tokens if not t.isspace() and t.strip()]
only_first = False
if not decline_all and len(content_tokens) >= 2:
for ct in content_tokens[1:]:
if ct[0].islower():
only_first = True
break
result_parts = []
content_idx = 0
for token in tokens:
if token.isspace() or not token.strip():
result_parts.append(token)
continue
stripped = token.strip()
if only_first and content_idx > 0:
result_parts.append(token)
content_idx += 1
continue
if _is_quoted(stripped):
result_parts.append(token)
content_idx += 1
continue
if stripped.isupper() and len(stripped) > 1:
result_parts.append(token)
content_idx += 1
continue
inflected = _inflect_word(stripped, case_gr)
if stripped[0].isupper():
inflected = inflected[0].upper() + inflected[1:]
result_parts.append(inflected)
content_idx += 1
return _restore_quoted("".join(result_parts), quoted)
def _inflect_word(word: str, case_gr: str) -> str:
"""Склонение одного слова через pymorphy3 с fallback на ручные правила."""
if not word or len(word) < 2 or case_gr == "nomn":
return word
if not MORPH_AVAILABLE or morph is None:
return _rules_decline(word, case_gr) or word
try:
parsed = morph.parse(word)
if not parsed:
return _rules_decline(word, case_gr) or word
best = parsed[0]
inflected = best.inflect({case_gr})
if inflected:
return inflected.word
return _rules_decline(word, case_gr) or word
except Exception:
return _rules_decline(word, case_gr) or word
def _rules_decline(word: str, case_gr: str) -> str:
"""Ручное склонение фамилий."""
w = word.lower()
# Мужские фамилии на -ов/-ев/-ёв
if w.endswith(('ов', 'ев', 'ёв')) and not w.endswith(('ова', 'ева', 'ёва')):
return {
'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а',
'ablt': word + 'ым', 'loct': word + 'е'
}.get(case_gr, word)
# Мужские фамилии на -ин
if w.endswith('ин') and not w.endswith('ина'):
return {
'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а',
'ablt': word + 'ым', 'loct': word + 'е'
}.get(case_gr, word)
# Женские фамилии на -ова/-ева/-ёва
if w.endswith(('ова', 'ева', 'ёва')):
stem = word[:-1]
return {
'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у',
'ablt': stem + 'ой', 'loct': stem + 'ой'
}.get(case_gr, word)
# Женские фамилии на -ина
if w.endswith('ина'):
stem = word[:-1]
return {
'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у',
'ablt': stem + 'ой', 'loct': stem + 'ой'
}.get(case_gr, word)
return word
def _extract_quoted(text: str):
"""Извлекает подстроки в кавычках, заменяет плейсхолдерами."""
if not text:
return text, {}
pattern = re.compile(r'(\u00ab[^\u00bb]*\u00bb|"[^"]*")')
placeholders = {}
counter = [0]
def _replace(m):
ph = f'__Q_{counter[0]}__'
placeholders[ph] = m.group(0)
counter[0] += 1
return ph
return pattern.sub(_replace, text), placeholders
def _restore_quoted(text: str, placeholders: dict) -> str:
for ph, original in placeholders.items():
text = text.replace(ph, original)
return text
def _is_quoted(word: str) -> bool:
w = word.strip()
if not w:
return False
return (w.startswith("\u00ab") and w.endswith("\u00bb")) or \
(w.startswith('"') and w.endswith('"'))
# ==================== ФОРМАТИРОВАНИЕ ФИО ====================
def get_short_fio(fio: str) -> str:
"""Фамилия И.О."""
if not fio:
return ""
parts = fio.split()
if len(parts) >= 2:
initials = parts[1][0] + "."
if len(parts) >= 3:
initials += parts[2][0] + "."
return parts[0] + " " + initials
return fio
def get_initials(fio: str) -> str:
"""И.О. Фамилия"""
if not fio:
return ""
parts = fio.split()
if len(parts) >= 2:
initials = parts[1][0] + "."
if len(parts) >= 3:
initials += parts[2][0] + "."
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",
"Должность",
"Табельный номер",
"Сведения о воинском учете",
"Сведения о командировках"
]
+403
View File
@@ -0,0 +1,403 @@
# -*- 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:
_merge_runs_for_loops(doc)
_process_loops(doc, loops)
# 4. Перезагрузка после циклов
buf = io.BytesIO()
doc.save(buf)
buf.seek(0)
doc = Document(buf)
# 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 if log_func else lambda x: None)
# 7. Подсветка незаполненных
_highlight_unmatched(doc)
# 8. Сохранение
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 _merge_runs_for_loops(doc):
"""Склеивает текст в runs, чтобы <!-- loop:x --> не был разбит на части."""
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
for para in doc.paragraphs:
full = para.text
if 'loop:' not in full and 'loop_end' not in full:
continue
# В этом параграфе есть маркер цикла — склеиваем все runs в один
if len(para.runs) <= 1:
continue
# Проверяем, не разбит ли маркер
for run in para.runs:
if 'loop:' in run.text or 'loop_end' in run.text:
# Маркер есть, но возможно разбит — склеиваем
combined = para.text
para.clear()
para.add_run(combined)
break
LOOP_START_RE = re.compile(r'<!--\s*loop:(\w+)\s*-->')
LOOP_END_RE = re.compile(r'<!--\s*loop_end\s*-->')
def _log_unmatched(doc, log_func=None):
"""Логирует незаменённые переменные в документе."""
unmatched = []
seen = set()
for para in doc.paragraphs:
for m in re.finditer(r'\{\{[^}]+\}\}|<[A-Za-z0-9_]+>', para.text):
var = m.group(0)
if var not in seen:
seen.add(var)
unmatched.append(var)
if unmatched and log_func:
log_func(f"⚠️ Незаменённые переменные ({len(unmatched)}): {', '.join(unmatched[:20])}{" ..." if len(unmatched) > 20 else ''}")
for var in unmatched:
pass
return len(unmatched) == 0
def _process_loops(doc, loops: Dict[str, List[Dict]]):
"""Обработка циклов через python-docx API (работает с вложенными циклами)."""
max_passes = 20
for _ in range(max_passes):
# Находим все маркеры циклов
markers = {} # idx -> (type, key)
for i, para in enumerate(doc.paragraphs):
text = para.text.strip()
m = LOOP_START_RE.search(text)
if m:
markers[i] = ('start', m.group(1))
elif LOOP_END_RE.search(text):
markers[i] = ('end', None)
if not markers:
break
# Сортируем по позиции
sorted_positions = sorted(markers.keys())
# Находим внешний цикл (самый первый start)
outer_start = None
outer_key = None
for pos in sorted_positions:
t, k = markers[pos]
if t == 'start':
outer_start = pos
outer_key = k
break
if outer_start is None:
break
# Находим соответствующий end (последний end после outer_start)
outer_end = None
for pos in reversed(sorted_positions):
t, k = markers[pos]
if t == 'end' and pos > outer_start:
outer_end = pos
break
if outer_end is None:
break
items = loops.get(outer_key, [])
if not items:
_remove_paras_range(doc, outer_start, outer_end)
continue
# Параграфы-шаблон (между start и end)
template_indices = list(range(outer_start + 1, outer_end))
if not template_indices:
continue
# Собираем XML шаблона для клонирования
body = doc.element.body
from lxml import etree as _etree
# Определяем, в какой элемент вставлять
ref_element = doc.paragraphs[outer_end]._element
# Для каждого экземпляра данных
for item_idx, item in enumerate(items, 1):
for tpl_idx in template_indices:
src_para = doc.paragraphs[tpl_idx]
# Клонируем XML-элемент параграфа
src_xml = _etree.tostring(src_para._element)
new_elem = _etree.fromstring(src_xml)
# Заменяем поля в тексте
for field, value in item.items():
search = '{{item.%s}}' % field
replace = str(value) if value else EMPTY_MARKER
_replace_in_xml(new_elem, search, replace)
_replace_in_xml(new_elem, '{{item.number}}', str(item_idx))
_replace_in_xml(new_elem, '{{number}}', str(item_idx))
# Вставляем после ref_element
ref_element.addnext(new_elem)
ref_element = new_elem
# Удаляем старый блок цикла
_remove_paras_range(doc, outer_start, outer_end)
def _remove_paras_range(doc, start_idx, end_idx):
"""Удаляет параграфы от start_idx до end_idx включительно."""
for idx in range(end_idx, start_idx - 1, -1):
if idx < len(doc.paragraphs):
el = doc.paragraphs[idx]._element
el.getparent().remove(el)
def _replace_in_xml(element, old, new):
"""Рекурсивно заменяет текст в XML-элементе (внутри <w:t>)."""
ns = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
if element.tag == ns + 't' or element.tag == 'w:t':
if old in (element.text or ''):
element.text = element.text.replace(old, new)
for child in element:
_replace_in_xml(child, old, new)
def _highlight_unmatched(doc):
"""Подсвечивает незаполненные переменные красным ({{...}} и старые <...>)."""
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def _has_unmatched(text):
return bool(UNMATCHED_PATTERN.search(text))
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 _has_unmatched(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 _has_unmatched(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 _has_unmatched(run.text):
_highlight_run(run)
+430
View File
@@ -0,0 +1,430 @@
# -*- coding: utf-8 -*-
"""DokoGen — Модуль импорта из Excel (опросный лист 152-ФЗ)"""
import re
import traceback
from datetime import datetime, timedelta
from typing import Callable, Optional
try:
import openpyxl
except ImportError:
openpyxl = None
def clean_value(val):
"""Очистка и нормализация значения ячейки Excel."""
if val is None:
return ''
if isinstance(val, (int, float)):
# Попытка распознать дату (серийный номер Excel)
if 10000 < val < 80000:
serial = int(val)
if serial > 60:
serial -= 1
dt = datetime(1899, 12, 30) + timedelta(days=serial)
return dt.strftime('%d.%m.%Y')
if isinstance(val, float) and val == int(val):
return str(int(val))
return str(val)
text = str(val).strip()
text = text.replace('\n', ' ').replace('\r', ' ')
text = ' '.join(text.split())
# Приведение регистра: если текст содержит только заглавные буквы РУ
if len(text) > 4 and any('А' <= c <= 'Я' or c == 'Ё' for c in text):
has_lower = any('а' <= c <= 'я' or c == 'ё' for c in text)
if not has_lower:
words = text.split()
if len(words) >= 3:
text = text.title()
else:
text = text.capitalize()
return text
def import_152fz(filepath: str, log_fn: Optional[Callable] = None) -> dict:
"""Импорт данных из опросного листа Excel (152-ФЗ)."""
if openpyxl is None:
raise ImportError("openpyxl не установлен. Установите: pip install openpyxl")
if log_fn is None:
log_fn = lambda msg: None
log_fn(f"Загрузка опросного листа: {filepath}")
data = {
'fullName': '', 'shortName': '', 'addressLegal': '', 'addressActual': '',
'email': '', 'phone': '', 'inn': '', 'ogrn': '', 'kpp': '',
'okved': '', 'chiefPosition': '', 'chiefFio': '',
'ispdnFio': '', 'ispdnPosition': '', 'paperDocuments': '', 'paperStorage': '',
'ispdnDepartment': '', 'ispdnEmail': '', 'ispdnPhone': '',
'informationSystems': [], 'commission': [], 'employeesAccess': [],
}
try:
wb = openpyxl.load_workbook(filepath, data_only=True, read_only=False)
except Exception as e:
log_fn(f"❌ Ошибка открытия файла: {e}")
return data
try:
_import_152_common(wb, data, log_fn)
_import_152_is_list(wb, data, log_fn)
_import_152_employees(wb, data, log_fn)
except Exception as e:
log_fn(f"❌ Ошибка при импорте: {e}")
log_fn(traceback.format_exc())
finally:
wb.close()
# Копирование полей ИСПДн в админа, если админ не заполнен
for src, dst in [('ispdnFio', 'administratorFio'), ('ispdnFio', 'adminFio'),
('ispdnPosition', 'administratorPosition'), ('ispdnPosition', 'adminPosition'),
('ispdnPhone', 'phone'), ('ispdnEmail', 'email')]:
if data.get(src) and not data.get(dst):
data[dst] = data[src]
_validate_company_data(data, log_fn)
return data
def _import_152_common(wb, data, log_fn):
"""Импорт общих сведений из листа 'Общие сведения'."""
sheet_name = None
for sn in wb.sheetnames:
if 'общие' in sn.lower():
sheet_name = sn
break
if not sheet_name:
log_fn("⚠ Лист 'Общие сведения' не найден")
return
ws = wb[sheet_name]
field_map = [
('полное наименование организации', 'fullName'),
('сокращенное наименование организации', 'shortName'),
('адрес организации \\(юридический\\)', 'addressLegal'),
('адрес организации \\(фактический\\)', 'addressActual'),
('инн', 'inn'),
('дата.*огрн', 'ogrnDate'),
('огрн', 'ogrn'),
('кпп', 'kpp'),
('основной оквэд', 'okved'),
('должность руководителя', 'chiefPosition'),
('фио руководителя', 'chiefFio'),
('должность.*защит.*информ|должность.*администрат', 'ispdnPosition'),
('фио.*защит.*информ|фио.*администрат', 'ispdnFio'),
('электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист', 'ispdnEmail'),
('телефон.*защит|телефон.*ответств|телефон.*специалист', 'ispdnPhone'),
('структур.*подраздел', 'ispdnDepartment'),
('номер.*договора|номер.*контракта', 'contractNumber'),
('дата.*договора|дата.*контракта', 'contractDate'),
('названи.*документ', 'paperDocuments'),
('место.*хранен', 'paperStorage'),
('должность.*ответственн', 'responsiblePosition'),
('фио.*ответственн', 'responsibleFio'),
('должность.*администрат.*(?:безопас|защит|пд)', 'administratorPosition'),
('фио.*администрат.*(?:безопас|защит|пд)', 'administratorFio'),
('сайт организации', 'site_name'),
('должность ответственного за сайт|должность.*отв.*сайт', 'site_responsible_position'),
('фио.*отв.*сайт|фио.*сайт', 'site_responsible_fio'),
('наименование организации.*разрабат.*сайт|наименование.*размещала.*сайт|разрабат.*сайт.*организац', 'site_service_provider'),
('инн.*разрабат.*сайт|инн.*размещала.*сайт', 'site_service_provider_inn'),
('размещение сайта|адрес расположения серверов|адрес.*сервер.*сайт', 'site_hosting'),
('адрес.*хостинг|адрес.*располож.*сервер', 'site_hosting_address'),
('электронная почта', 'email'),
('телефон', 'phone'),
]
for row in ws.iter_rows(min_row=1, values_only=True):
field_raw = clean_value(row[0]) if row[0] is not None else ''
field = field_raw.lower()
value = clean_value(row[1]) if len(row) > 1 else ''
if not field:
continue
for keyword, key in field_map:
if re.search(keyword, field):
if value:
data[key] = value
log_fn(f" {keyword}: {value}")
break
def _import_152_is_list(wb, data, log_fn):
"""Импорт информационных систем из остальных листов."""
is_sheets = [sn for sn in wb.sheetnames if sn not in ('Общие сведения', 'Сотрудники')]
if not is_sheets:
log_fn("⚠ Листы ИС не найдены")
return
is_list = []
for sheet_name in is_sheets:
ws = wb[sheet_name]
rows = []
for row in ws.iter_rows(min_row=1, values_only=True):
vals = [clean_value(c) for c in row]
field = (vals[0] or '').strip() if vals else ''
if not field:
continue
rows.append(vals)
if len(rows) < 2:
continue
is_obj = {
'name': '', 'description': '', 'room': '', 'employee': '', 'position': '',
'personalDataList': [], 'pd_subjects_list': [], 'pd_actions': [],
'usersList': [], 'isInternet': False, 'personalDataCount': 'менее 100 000',
'defense_tools': [], 'processing_mode': '',
}
current_section = 'header'
pd_items_raw = []
subjects_raw = []
actions_raw = []
for vals in rows:
field = (vals[0] or '').strip()
field_lower = field.lower()
val_b = clean_value(vals[1]) if len(vals) > 1 else ''
is_checked = val_b.upper() in ('ДА', 'YES', '', '', '1', 'TRUE', 'ЕСТЬ')
if 'цель обработки пд' in field_lower or ('цель обработки' in field_lower and ':' in field):
current_section = 'purpose'
continue
if 'перечисленные пдн принадлежат' in field_lower or 'принадлеж' in field_lower:
current_section = 'subjects'
continue
if 'количество записей' in field_lower or 'количество субъект' in field_lower:
if val_b:
is_obj['personalDataCount'] = val_b
continue
if 'перечень действий с пд' in field_lower or ('действи' in field_lower and 'пд' in field_lower):
current_section = 'actions'
continue
if 'способ обработки пд' in field_lower:
if val_b:
is_obj['processing_mode'] = val_b
continue
if 'интернет' in field_lower:
is_obj['isInternet'] = is_checked
continue
if current_section == 'header':
if 'перечень пд' in field_lower:
current_section = 'pd_items'
elif 'название ис' in field_lower or 'наименование ис' in field_lower or 'наименование информационной' in field_lower:
is_obj['name'] = val_b
log_fn(f" ИС: {val_b}")
elif 'сотрудник' in field_lower and 'должност' not in field_lower:
is_obj['employee'] = val_b
if val_b:
parts = [p.strip() for p in val_b.replace('\n', ';').split(';') if p.strip()]
for part in parts:
cleaned = part.strip()
if ':' in cleaned and len(cleaned.split(':')[0]) < 20:
cleaned = cleaned.split(':', 1)[1].strip()
if cleaned and cleaned not in is_obj['usersList']:
is_obj['usersList'].append(cleaned)
elif 'должность сотрудника' in field_lower:
is_obj['position'] = val_b
elif 'наименование отдела' in field_lower:
pass
elif 'номер кабинета' in field_lower:
is_obj['room'] = val_b
elif 'значение' in field_lower or 'примечание' in field_lower:
continue
elif val_b and 'выбрать' not in field_lower:
if not is_obj['description']:
is_obj['description'] = f"{field}: {val_b}"
else:
is_obj['description'] += f"\n{field}: {val_b}"
elif current_section == 'pd_items':
if is_checked and 'выбрать' not in field_lower and field_lower != 'значение':
pd_items_raw.append(field)
if 'цель обработки' in field_lower:
current_section = 'purpose'
elif current_section == 'purpose':
purpose_val = field or val_b or ''
if purpose_val and 'выбрать' not in purpose_val.lower():
is_obj['purpose'] = purpose_val
log_fn(f" Цель: {purpose_val[:80]}")
current_section = 'header'
elif current_section == 'subjects':
if is_checked and 'выбрать' not in field_lower:
subjects_raw.append(field)
if 'количество записей' in field_lower or 'действи' in field_lower:
continue
elif current_section == 'actions':
if is_checked and 'выбрать' not in field_lower and 'защита' not in field_lower[:20]:
actions_raw.append(field)
if 'защита' in field_lower and 'действи' not in field_lower:
current_section = 'defense'
elif current_section == 'defense':
defense_keywords = {
'антивирус': 'Антивирус',
'крипто': 'СКЗИ',
'межсетев': 'Межсетевой экран',
'сзи.*от несанкционирован': 'СЗИ от НСД',
'обнаружен.*вторжен': 'СОВ',
'программные.*средств.*модул': 'Доверенная загрузка',
'доверен.*загрузк': 'Доверенная загрузка',
'гарантирован': 'Доверенная загрузка',
}
matched_label = None
for kw, label in defense_keywords.items():
if re.search(kw, field_lower):
matched_label = label
break
if matched_label and val_b and 'выбрать' not in field_lower:
is_obj['defense_tools'].append(f"{matched_label}: {val_b}")
elif matched_label:
pass
elif 'куда' in field_lower or 'передаются' in field_lower:
current_section = 'header'
elif 'способ обработки' in field_lower or 'локальную сеть' in field_lower or 'интернет' in field_lower:
current_section = 'header'
if pd_items_raw:
is_obj['personalDataList'] = pd_items_raw
log_fn(f" ПДн: {len(pd_items_raw)}")
if subjects_raw:
is_obj['pd_subjects_list'] = subjects_raw
log_fn(f" Субъектов: {len(subjects_raw)}")
if actions_raw:
is_obj['pd_actions'] = actions_raw
log_fn(f" Действий: {len(actions_raw)}")
if not is_obj['name']:
is_obj['name'] = sheet_name.strip()
log_fn(f" ⚠ ИС без названия — использовано имя листа: {is_obj['name']}")
# Автоопределение категорий ПДн
pd_cats = []
all_pd_text = ' '.join(p.lower() for p in pd_items_raw)
special_kw = ['состоян.*здоров', 'национальн', 'политическ', 'религиозн', 'философ', 'судимост', 'интимн']
if any(re.search(kw, all_pd_text) for kw in special_kw):
pd_cats.append('специальные')
bio_kw = ['биометрическ', 'изображен.*лиц', 'голос.*человек', 'папилляр', 'дактилоскоп', 'фото.*изображен']
if any(re.search(kw, all_pd_text) for kw in bio_kw):
pd_cats.append('биометрические')
if pd_items_raw:
pd_cats.append('иные')
if pd_cats:
is_obj['personalDataCategory'] = pd_cats
log_fn(f" Категории ПДн: {', '.join(pd_cats)}")
is_list.append(is_obj)
log_fn(f"{is_obj['name']} — импортирована")
if is_list:
data['informationSystems'] = is_list
log_fn(f"\n✅ Всего импортировано ИС: {len(is_list)}")
def _import_152_employees(wb, data, log_fn):
"""Импорт комиссии и сотрудников из листа 'Сотрудники'."""
sheet_name = None
for sn in wb.sheetnames:
if 'сотруд' in sn.lower():
sheet_name = sn
break
if not sheet_name:
log_fn("⚠ Лист «Сотрудники» не найден")
return
ws = wb[sheet_name]
rows = []
for row in ws.iter_rows(min_row=1, values_only=True):
vals = [clean_value(c) for c in row]
if not vals or not vals[0]:
continue
rows.append(vals)
if not rows:
log_fn("⚠ Лист «Сотрудники» пуст")
return
commission = []
in_commission = False
COMMISSION_ROLES = {
'председатель': 'Председатель комиссии',
'секретарь': 'Секретарь комиссии',
'заместитель председателя': 'Заместитель председателя комиссии',
}
employees = []
in_employees = False
for vals in rows:
col_a = (vals[0] or '').strip()
col_b = clean_value(vals[1]) if len(vals) > 1 else ''
col_c = clean_value(vals[2]) if len(vals) > 2 else ''
col_d = clean_value(vals[3]) if len(vals) > 3 else ''
col_e = clean_value(vals[4]) if len(vals) > 4 else ''
a_lower = col_a.lower()
if 'состав комиссии' in a_lower:
in_commission = True
in_employees = False
log_fn(" Секция: состав комиссии")
continue
if 'перечень сотрудников' in a_lower:
in_commission = False
in_employees = True
log_fn(" Секция: сотрудники с доступом к ПДн")
continue
if a_lower in ('№ п/п', '', 'номер', ''):
continue
if in_commission:
role = 'Член комиссии'
if col_e:
e_lower = col_e.lower()
for keyword, role_name in COMMISSION_ROLES.items():
if keyword in e_lower:
role = role_name
break
if not col_b and not col_c:
continue
commission.append({
'role': role,
'position': col_b,
'fio': col_c.replace('\n', ' '),
})
if in_employees:
fio = col_c if col_c else col_d
if not fio:
continue
employee = {
'position': col_b,
'fio': fio.replace('\n', ' '),
}
if col_e:
employee['is_list'] = [s.strip() for s in col_e.split('\n') if s.strip()]
employees.append(employee)
if commission:
commission = [c for c in commission if c['position'] or c['fio']]
data['commission'] = commission
log_fn(f" Комиссия: {len(commission)} чел.")
for m in commission[:3]:
log_fn(f" {m['role']}: {m['position'][:40]}{m['fio'][:40]}")
if employees:
data['employeesAccess'] = employees
log_fn(f" Сотрудников с доступом: {len(employees)}")
def _validate_company_data(data, log_fn):
"""Проверка обязательных полей."""
required = ['fullName', 'inn']
missing = [f for f in required if not data.get(f)]
if missing:
log_fn(f"⚠️ ОБЯЗАТЕЛЬНЫЕ ПОЛЯ НЕ ЗАПОЛНЕНЫ: {', '.join(missing)}")
else:
log_fn("✅ Все обязательные поля заполнены")
inn = (data.get('inn') or '').replace(' ', '')
if inn and (not inn.isdigit() or len(inn) not in (10, 12)):
log_fn(f"⚠️ ИНН подозрительного формата: «{inn}»")
+148
View File
@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
"""DokoGen — Модели данных (только 152-ФЗ, только нужные поля)"""
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Any
@dataclass
class CommissionMember:
"""Член комиссии по ПДн"""
role: str = "член комиссии"
position: str = ""
fio: str = ""
@dataclass
class InformationSystem:
"""Информационная система (обработка ПДн)"""
name: str = ""
description: str = ""
is_local_network: bool = False
is_internet: bool = False
defence_level: str = ""
threat_type: str = "3"
category: str = ""
pd_list: str = ""
pd_count: str = "менее 100 000"
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 = ""
@dataclass
class PaperDocument:
"""Бумажный документ с ПДн"""
name: str = ""
storage: str = ""
@dataclass
class Company:
"""Организация — основной объект (152-ФЗ)"""
# Полное название
full_name: str = ""
company_name_1: str = "" # именительный
company_name_2: str = "" # родительный
company_name_3: str = "" # дательный
company_name_4: str = "" # винительный
company_name_5: str = "" # творительный
company_name_6: str = "" # предложный
# Краткое название
short_name: str = ""
short_name_1: str = ""
short_name_2: str = ""
short_name_3: str = ""
short_name_4: str = ""
short_name_5: str = ""
short_name_6: str = ""
# Адрес и реквизиты
address: str = ""
city_name: str = ""
inn: str = ""
ogrn: str = ""
ogrn_date: str = ""
kpp: str = ""
# Должностные лица
chief_position: str = ""
chief_fio: str = ""
responsible_position: str = ""
responsible_fio: str = ""
admin_position: str = ""
admin_fio: str = ""
# Документ
contract_number: str = ""
contract_date: str = ""
# Списки
commission: List[CommissionMember] = field(default_factory=list)
information_systems: List[InformationSystem] = field(default_factory=list)
paper_documents_list: List[PaperDocument] = field(default_factory=list)
employees_access: List[Dict[str, str]] = field(default_factory=list)
def update_declensions(self, inflect_func):
"""Обновить склонения полного названия"""
if self.full_name:
inflected = inflect_func(self.full_name)
self.company_name_1 = self.full_name
self.company_name_2 = inflected.get("gent", "")
self.company_name_3 = inflected.get("datv", "")
self.company_name_4 = inflected.get("accs", "")
self.company_name_5 = inflected.get("ablt", "")
self.company_name_6 = inflected.get("loct", "")
def update_short_declensions(self, inflect_func):
"""Обновить склонения сокращённого названия"""
if self.short_name:
inflected = inflect_func(self.short_name)
self.short_name_1 = self.short_name
self.short_name_2 = inflected.get("gent", "")
self.short_name_3 = inflected.get("datv", "")
self.short_name_4 = inflected.get("accs", "")
self.short_name_5 = inflected.get("ablt", "")
self.short_name_6 = inflected.get("loct", "")
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Company":
d = dict(data)
if "commission" in d and isinstance(d["commission"], list):
d["commission"] = [
CommissionMember(**c) if isinstance(c, dict) else c
for c in d["commission"]
]
if "information_systems" in d and isinstance(d["information_systems"], list):
d["information_systems"] = [
InformationSystem(**isys) if isinstance(isys, dict) else isys
for isys in d["information_systems"]
]
if "paper_documents_list" in d and isinstance(d["paper_documents_list"], list):
d["paper_documents_list"] = [
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})
def get_folder_name(self) -> str:
if self.short_name:
return self.short_name
if self.full_name:
return self.full_name[:30]
return "Новая_организация"
+1571
View File
File diff suppressed because it is too large Load Diff
+395
View File
@@ -0,0 +1,395 @@
# -*- coding: utf-8 -*-
"""DokoGen — Справочник переменных шаблонов и резолвер значений"""
from datetime import datetime
from typing import Dict, Any, List, Optional
import re
from . import declension as dc
from .models import Company, CommissionMember
# ============================================================
# ОПИСАНИЕ ПЕРЕМЕННЫХ
# ============================================================
# Формат: (имя_переменной, группа, описание, ключ_резолвера)
# Группы: A — общие, B — 152-ФЗ
VARIABLE_DEFS = [
# === Полное название организации (6 падежей) ===
('{{company_full_name}}', 'A', 'Полное наименование организации (им.п.)', 'company:full_name'),
('{{company_full_name_genitive}}', 'A', 'Полное наименование (род.п.)', 'decl:full_name:gent'),
('{{company_full_name_dative}}', 'A', 'Полное наименование (дат.п.)', 'decl:full_name:datv'),
('{{company_full_name_accs}}', 'A', 'Полное наименование (вин.п.)', 'decl:full_name:accs'),
('{{company_full_name_ablt}}', 'A', 'Полное наименование (твор.п.)', 'decl:full_name:ablt'),
('{{company_full_name_loct}}', 'A', 'Полное наименование (пр.п.)', 'decl:full_name:loct'),
# === Краткое название организации (6 падежей) ===
('{{company_short_name}}', 'A', 'Краткое наименование (им.п.)', 'company:short_name'),
('{{company_short_name_genitive}}', 'A', 'Краткое наименование (род.п.)', 'decl:short_name:gent'),
('{{company_short_name_dative}}', 'A', 'Краткое наименование (дат.п.)', 'decl:short_name:datv'),
('{{company_short_name_accs}}', 'A', 'Краткое наименование (вин.п.)', 'decl:short_name:accs'),
('{{company_short_name_ablt}}', 'A', 'Краткое наименование (твор.п.)', 'decl:short_name:ablt'),
('{{company_short_name_loct}}', 'A', 'Краткое наименование (пр.п.)', 'decl:short_name:loct'),
# === Должностные лица ===
('{{chief_position}}', 'A', 'Должность руководителя', 'company:chief_position'),
('{{chief_fio_initials}}', 'A', 'И.О. Фамилия руководителя', 'initials:chief'),
('{{chief_fio_short}}', 'A', 'Фамилия И.О. руководителя', 'short_fio:chief'),
('{{responsible_position}}', 'A', 'Должность ответственного', 'company:responsible_position'),
('{{responsible_fio_initials}}', 'A', 'И.О. Фамилия ответственного', 'initials:responsible'),
('{{responsible_fio_short}}', 'A', 'Фамилия И.О. ответственного', 'short_fio:responsible'),
('{{admin_position}}', 'A', 'Должность администратора', 'company:admin_position'),
('{{admin_fio_initials}}', 'A', 'И.О. Фамилия администратора', 'initials:admin'),
('{{admin_fio_short}}', 'A', 'Фамилия И.О. администратора', 'short_fio:admin'),
# === Сведения об организации ===
('{{company_address}}', 'A', 'Адрес организации', 'company:address'),
('{{city_name}}', 'A', 'Город', 'company:city_name'),
('{{company_inn}}', 'A', 'ИНН', 'company:inn'),
('{{company_ogrn}}', 'A', 'ОГРН', 'company:ogrn'),
('{{company_ogrn_date}}', 'A', 'Дата ОГРН', 'company:ogrn_date'),
('{{company_kpp}}', 'A', 'КПП', 'company:kpp'),
# === Комиссия ===
('{{commission_list}}', 'A', 'Список комиссии', 'commission_list'),
# === Специальные переменные ===
('{{date_year}}', 'A', 'Текущий год', 'date:year'),
('{{date}}', 'A', 'Текущая дата', 'date:full'),
('{{document_date}}', 'A', 'Дата документа', 'doc_date'),
('{{document_number}}', 'A', 'Номер документа', 'doc_number'),
# === 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'),
]
# Маппинг старых переменных <...> → {{...}}
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}}',
'<SurveyDate>': '{{document_date}}',
}
VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}')
OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>')
# ============================================================
# ПОСТРОЕНИЕ СЛОВАРЯ ЗАМЕН
# ============================================================
def build_replacements(
company: Company,
is_list: List = None,
doc_number: str = "",
doc_date: str = "",
direction: str = "152fz",
) -> Dict[str, str]:
"""Строит полный словарь замен {{переменная}} → значение."""
if is_list is None:
is_list = []
# ---------- Комиссия ----------
commission_data = _build_commission_data(company)
# ---------- Контекст ----------
ctx = {
'company': company,
'is_list': is_list,
'commission_data': commission_data,
'doc_number': doc_number or '',
'doc_date': doc_date or '',
'direction': direction,
}
replacements = {}
for var_name, group, desc, source in VARIABLE_DEFS:
if direction == '152fz' and group not in ('A', 'B'):
continue
value = _resolve(source, ctx)
replacements[var_name] = str(value) if value else ''
# Индексированные переменные ИС (is_name_1, is_pd_list_2 и т.д.)
for i, isys in enumerate(is_list, 1):
_add_is_indexed_vars(replacements, isys, i)
# Циклы
replacements['_loops'] = _build_loop_data(company, is_list)
return replacements
def _build_commission_data(company: Company) -> Dict[str, str]:
data = {}
members = company.commission or []
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}"
lines.append(line)
data['commission_list'] = '\n'.join(lines) if lines else ''
return data
def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int):
fields = {
'is_name': isys.name or '',
'is_address': getattr(isys, 'address', '') or '',
'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_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 '',
}
for f, v in fields.items():
replacements[f'{{{{{f}_{idx}}}}}'] = str(v)
def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
loops = {}
# Комиссия
members = company.commission or []
if members:
items = []
for m in members:
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}",
})
loops['commission'] = items
# ИС
if is_list:
items = []
for isys in is_list:
items.append({
'name': isys.name or '',
'description': isys.description 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 '',
'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 '',
'processing_modes': isys.processing_modes or '',
'pd_subjects': '; '.join(isys.pd_subjects_list or []) or '',
'is_purpose': isys.purpose or '',
})
loops['information_systems'] = items
# Бумажные документы
paper_list = getattr(company, 'paper_documents_list', None) or []
if paper_list:
items = []
for p in paper_list:
items.append({
'document': p.name or '',
'storage': p.storage or '',
})
loops['paper_documents'] = items
# Сотрудники с доступом
employees = getattr(company, 'employees_access', None) or []
if employees:
items = []
for e in employees:
items.append({
'position': e.get('position', ''),
'fio': e.get('fio', ''),
})
loops['employees_access'] = items
return loops
# ============================================================
# РЕЗОЛВЕР ЗНАЧЕНИЙ
# ============================================================
def _resolve(source: str, ctx: Dict) -> str:
"""Преобразует ключ-источник в значение, подставляя из контекста."""
company = ctx.get('company')
is_list = ctx.get('is_list', [])
commission_data = ctx.get('commission_data', {})
doc_number = ctx.get('doc_number', '')
doc_date = ctx.get('doc_date', '')
# Прямые поля Company
if source.startswith('company:'):
field = source[8:]
return str(getattr(company, field, '') or '')
# Склонение названия
if source.startswith('decl:'):
_, field, case = source.split(':')
name = getattr(company, field, '') or ''
return dc.company_name_decline(name, case) if name else ''
# Форматы ФИО
if source.startswith('short_fio:'):
who = source.split(':', 1)[1]
fio = _get_fio(company, who)
return dc.get_short_fio(fio) if fio else ''
if source.startswith('initials:'):
who = source.split(':', 1)[1]
fio = _get_fio(company, who)
return dc.get_initials(fio) if fio else ''
# Комиссия
if source in commission_data:
return str(commission_data[source])
# Даты
if source.startswith('date:'):
fmt = source.split(':', 1)[1]
now = datetime.now()
if fmt == 'year':
return str(now.year)
return now.strftime('%d.%m.%Y')
if source == 'doc_date':
return doc_date or datetime.now().strftime('%d.%m.%Y')
if source == 'doc_number':
num = str(doc_number).strip()
return re.sub(r'^[Nn]?[oо]?[№#]\s*', '', num) or ''
# Требования защищённости
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 ''
def _get_fio(company: Company, who: str) -> str:
mapping = {
'chief': 'chief_fio',
'responsible': 'responsible_fio',
'admin': 'admin_fio',
}
field = mapping.get(who, who + '_fio')
return getattr(company, field, '') or ''
def _get_security_text(ctx: Dict) -> str:
"""Возвращает текст требований для уровня защищённости."""
defence_level = ctx.get('extended_fields', {}).get('defence_level', '4')
is_list = ctx.get('is_list', [])
if not defence_level and is_list:
defence_level = str(getattr(is_list[0], 'defence_level', '4') or '4')
dl = str(defence_level).strip()[0]
TEXTS = {
'1': """Для обеспечения 1-го уровня защищённости...""",
'2': """Для обеспечения 2-го уровня защищённости...""",
'3': """Для обеспечения 3-го уровня защищённости...""",
'4': """Для обеспечения 4-го уровня защищённости персональных данных необходимо:
режим безопасности помещений;
сохранность носителей ПДн;
утверждение перечня лиц с доступом к ПДн;
использование сертифицированных СЗИ.""",
}
return TEXTS.get(dl, TEXTS['4'])