fix: _log_unmatched сигнатура (3 аргумента)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.autosave/
|
||||
Сгенерированные_документы/
|
||||
*.result.docx
|
||||
@@ -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__ = "ДоКоГеНеРаТоР"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
"Антивирусное ПО",
|
||||
"СКЗИ (криптография)",
|
||||
"Межсетевой экран",
|
||||
"СЗИ от НСД",
|
||||
"СОВ (обнаружение вторжений)",
|
||||
"Доверенная загрузка",
|
||||
"SIEM-система",
|
||||
"DLP-система",
|
||||
"VPN",
|
||||
"Система парольной защиты",
|
||||
"Антиспам",
|
||||
"Средства резервного копирования"
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
"Сбор персональных данных",
|
||||
"Запись персональных данных",
|
||||
"Систематизация персональных данных",
|
||||
"Накопление персональных данных",
|
||||
"Хранение персональных данных",
|
||||
"Уточнение (обновление, изменение) персональных данных",
|
||||
"Извлечение персональных данных",
|
||||
"Использование персональных данных",
|
||||
"Передача (распространение, предоставление, доступ) персональных данных",
|
||||
"Обезличивание персональных данных",
|
||||
"Блокирование персональных данных",
|
||||
"Удаление персональных данных",
|
||||
"Уничтожение персональных данных",
|
||||
"Трансграничная передача персональных данных",
|
||||
"Автоматизированная обработка персональных данных",
|
||||
"Неавтоматизированная обработка персональных данных",
|
||||
"Смешанная обработка персональных данных",
|
||||
"Формирование и ведение баз данных",
|
||||
"Передача данных третьим лицам",
|
||||
"Внутренняя передача данных между подразделениями"
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
"работники оператора",
|
||||
"клиенты",
|
||||
"контрагенты",
|
||||
"родственники работников",
|
||||
"соискатели",
|
||||
"иные лица"
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
[
|
||||
"Фамилия, имя, отчество",
|
||||
"Дата рождения",
|
||||
"Место рождения",
|
||||
"Паспортные данные",
|
||||
"Адрес регистрации",
|
||||
"Адрес проживания",
|
||||
"ИНН",
|
||||
"СНИЛС",
|
||||
"Номер телефона",
|
||||
"Адрес электронной почты",
|
||||
"Образование",
|
||||
"Профессия",
|
||||
"Сведения о доходах",
|
||||
"Семейное положение",
|
||||
"Состав семьи",
|
||||
"Состояние здоровья",
|
||||
"Национальность",
|
||||
"Гражданство",
|
||||
"Сведения о судимости",
|
||||
"Фотография",
|
||||
"Биометрические данные",
|
||||
"Рабочий e-mail",
|
||||
"Должность",
|
||||
"Табельный номер",
|
||||
"Сведения о воинском учете",
|
||||
"Сведения о командировках"
|
||||
]
|
||||
@@ -0,0 +1,385 @@
|
||||
# -*- 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'(?:<!--|<!--)?\s*loop:(\w+)(?:\s*(?:-->|-->))?')
|
||||
LOOP_END = re.compile(r'(?:<!--|<!--)?\s*loop_end(?:\s*(?:-->|-->))?')
|
||||
|
||||
|
||||
|
||||
|
||||
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 _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)
|
||||
@@ -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}»")
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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'])
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DokoGen — Генератор документов 152-ФЗ
|
||||
Точка входа: 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__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
python-docx>=1.1.0
|
||||
pymorphy3>=2.0.0
|
||||
openpyxl>=3.1.0
|
||||
lxml>=5.0.0
|
||||
Reference in New Issue
Block a user