fix: правильная структура + main.py в корне
- Пакет dokogen/ в подпапке - main.py и requirements.txt в корне - Импорт: from dokogen.ui import main
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
.autosave/
|
||||||
|
Сгенерированные_документы/
|
||||||
|
*.result.docx
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# -*- 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
|
||||||
|
|
||||||
|
__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,378 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""DokoGen — Генератор документов из шаблонов DOCX"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional, Callable
|
||||||
|
|
||||||
|
try:
|
||||||
|
from docx import Document
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
from lxml import etree
|
||||||
|
except ImportError:
|
||||||
|
Document = None
|
||||||
|
|
||||||
|
from .variables import OLD_VAR_MAP, OLD_VAR_PATTERN, VAR_PATTERN
|
||||||
|
|
||||||
|
# Стандартный шаблон для незаполненных переменных
|
||||||
|
EMPTY_MARKER = '—'
|
||||||
|
|
||||||
|
|
||||||
|
def process_template(
|
||||||
|
template_path: str,
|
||||||
|
replacements: Dict[str, str],
|
||||||
|
output_path: Optional[str] = None,
|
||||||
|
log_func: Optional[Callable] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Обрабатывает шаблон DOCX: подстановка переменных, циклы, сохранение."""
|
||||||
|
if Document is None:
|
||||||
|
raise ImportError("python-docx не установлен. Установите: pip install python-docx")
|
||||||
|
|
||||||
|
if not os.path.exists(template_path):
|
||||||
|
raise FileNotFoundError(f"Шаблон не найден: {template_path}")
|
||||||
|
|
||||||
|
if output_path is None:
|
||||||
|
base, ext = os.path.splitext(template_path)
|
||||||
|
output_path = f"{base}_result{ext}"
|
||||||
|
|
||||||
|
doc = Document(template_path)
|
||||||
|
|
||||||
|
# 1. Миграция старых переменных <...> → {{...}}
|
||||||
|
_migrate_old_vars(doc)
|
||||||
|
|
||||||
|
# 2. Удаление proofErr
|
||||||
|
_strip_prooferr(doc)
|
||||||
|
|
||||||
|
# 3. Обработка циклов (loop/loop_end)
|
||||||
|
loops = replacements.pop('_loops', {})
|
||||||
|
if loops:
|
||||||
|
_process_loops(doc, loops)
|
||||||
|
|
||||||
|
# 4. Перезагрузка после циклов
|
||||||
|
buf = io.BytesIO()
|
||||||
|
doc.save(buf)
|
||||||
|
buf.seek(0)
|
||||||
|
doc = Document(buf)
|
||||||
|
|
||||||
|
# 5. Плоская замена переменных (2 прохода)
|
||||||
|
for _ in range(2):
|
||||||
|
_replace_in_paragraphs(doc.paragraphs, replacements)
|
||||||
|
_replace_in_tables(doc.tables, replacements)
|
||||||
|
_replace_in_headers_footers(doc, replacements)
|
||||||
|
|
||||||
|
# 6. Подсветка незаполненных
|
||||||
|
_highlight_unmatched(doc)
|
||||||
|
|
||||||
|
# 7. Сохранение
|
||||||
|
doc.save(output_path)
|
||||||
|
|
||||||
|
if log_func:
|
||||||
|
log_func(f"✅ {os.path.basename(output_path)}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== МИГРАЦИЯ СТАРЫХ ПЕРЕМЕННЫХ ====================
|
||||||
|
|
||||||
|
def _migrate_old_vars(doc):
|
||||||
|
"""Заменяет <OldVar> на {{new_var}} в документе."""
|
||||||
|
def _migrate_text(text):
|
||||||
|
return OLD_VAR_PATTERN.sub(lambda m: OLD_VAR_MAP.get(m.group(0), m.group(0)), text)
|
||||||
|
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
new_text = _migrate_text(para.text)
|
||||||
|
if new_text != para.text:
|
||||||
|
if para.runs:
|
||||||
|
first = para.runs[0]
|
||||||
|
para.clear()
|
||||||
|
run = para.add_run(new_text)
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
else:
|
||||||
|
para.clear()
|
||||||
|
para.add_run(new_text)
|
||||||
|
|
||||||
|
for table in doc.tables:
|
||||||
|
for row in table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
for para in cell.paragraphs:
|
||||||
|
new_text = _migrate_text(para.text)
|
||||||
|
if new_text != para.text:
|
||||||
|
if para.runs:
|
||||||
|
first = para.runs[0]
|
||||||
|
para.clear()
|
||||||
|
run = para.add_run(new_text)
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
else:
|
||||||
|
para.clear()
|
||||||
|
para.add_run(new_text)
|
||||||
|
|
||||||
|
for section in doc.sections:
|
||||||
|
for hf in [section.header, section.footer,
|
||||||
|
section.first_page_header, section.first_page_footer]:
|
||||||
|
if hf:
|
||||||
|
for para in hf.paragraphs:
|
||||||
|
new_text = _migrate_text(para.text)
|
||||||
|
if new_text != para.text:
|
||||||
|
if para.runs:
|
||||||
|
first = para.runs[0]
|
||||||
|
para.clear()
|
||||||
|
run = para.add_run(new_text)
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
else:
|
||||||
|
para.clear()
|
||||||
|
para.add_run(new_text)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== ЗАМЕНА В ЭЛЕМЕНТАХ ====================
|
||||||
|
|
||||||
|
def _replace_in_paragraphs(paragraphs, replacements):
|
||||||
|
for para in paragraphs:
|
||||||
|
full = para.text
|
||||||
|
# Пропускаем строки циклов
|
||||||
|
if 'loop:' in full or 'loop_end' in full:
|
||||||
|
continue
|
||||||
|
matches = VAR_PATTERN.findall(full)
|
||||||
|
if not matches:
|
||||||
|
continue
|
||||||
|
new_text = full
|
||||||
|
for var in matches:
|
||||||
|
val = replacements.get(var)
|
||||||
|
if val is not None:
|
||||||
|
new_text = new_text.replace(var, str(val))
|
||||||
|
if new_text != full:
|
||||||
|
if para.runs:
|
||||||
|
first = para.runs[0]
|
||||||
|
para.clear()
|
||||||
|
run = para.add_run(new_text)
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
else:
|
||||||
|
para.clear()
|
||||||
|
para.add_run(new_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_in_tables(tables, replacements):
|
||||||
|
for table in tables:
|
||||||
|
for row in table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
for para in cell.paragraphs:
|
||||||
|
full = para.text
|
||||||
|
matches = VAR_PATTERN.findall(full)
|
||||||
|
if not matches:
|
||||||
|
continue
|
||||||
|
new_text = full
|
||||||
|
for var in matches:
|
||||||
|
val = replacements.get(var)
|
||||||
|
if val is not None:
|
||||||
|
new_text = new_text.replace(var, str(val))
|
||||||
|
if new_text != full:
|
||||||
|
if para.runs:
|
||||||
|
first = para.runs[0]
|
||||||
|
para.clear()
|
||||||
|
run = para.add_run(new_text)
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
else:
|
||||||
|
para.clear()
|
||||||
|
para.add_run(new_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_in_headers_footers(doc, replacements):
|
||||||
|
for section in doc.sections:
|
||||||
|
for hf in [section.header, section.footer,
|
||||||
|
section.first_page_header, section.first_page_footer]:
|
||||||
|
if hf:
|
||||||
|
_replace_in_paragraphs(hf.paragraphs, replacements)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_run_style(source, target):
|
||||||
|
"""Копирует форматирование из одного run в другой."""
|
||||||
|
try:
|
||||||
|
target.bold = source.bold
|
||||||
|
target.italic = source.italic
|
||||||
|
target.underline = source.underline
|
||||||
|
if source.font:
|
||||||
|
target.font.size = source.font.size
|
||||||
|
target.font.name = source.font.name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_prooferr(doc):
|
||||||
|
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
for elem in doc.element.iter():
|
||||||
|
for child in list(elem):
|
||||||
|
if child.tag == f'{{{ns}}}proofErr':
|
||||||
|
elem.remove(child)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== ОБРАБОТКА ЦИКЛОВ ====================
|
||||||
|
|
||||||
|
LOOP_START = re.compile(r'(?:<!--|<!--)?\s*loop:(\w+)(?:\s*(?:-->|-->))?')
|
||||||
|
LOOP_END = re.compile(r'(?:<!--|<!--)?\s*loop_end(?:\s*(?:-->|-->))?')
|
||||||
|
|
||||||
|
|
||||||
|
def _process_loops(doc, loops: Dict[str, List[Dict]]):
|
||||||
|
body = doc.element.body
|
||||||
|
max_passes = 20
|
||||||
|
for _ in range(max_passes):
|
||||||
|
body_str = etree.tostring(body, encoding='unicode')
|
||||||
|
# Находим все ключи циклов
|
||||||
|
found = set(LOOP_START.findall(body_str))
|
||||||
|
if not found:
|
||||||
|
break
|
||||||
|
changed = False
|
||||||
|
for key in sorted(found):
|
||||||
|
items = loops.get(key, [])
|
||||||
|
if not items:
|
||||||
|
_remove_loop_block(body, key)
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
_expand_loop(body, key, items)
|
||||||
|
changed = True
|
||||||
|
body = doc.element.body
|
||||||
|
if not changed:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_loop(body, key: str, items: List[Dict]):
|
||||||
|
body_str = etree.tostring(body, encoding='unicode')
|
||||||
|
|
||||||
|
markers = []
|
||||||
|
for m in LOOP_START.finditer(body_str):
|
||||||
|
if m.group(1) == key:
|
||||||
|
markers.append((m.start(), True, m.end()))
|
||||||
|
for m in LOOP_END.finditer(body_str):
|
||||||
|
markers.append((m.start(), False, m.end()))
|
||||||
|
|
||||||
|
if len(markers) < 2:
|
||||||
|
return
|
||||||
|
markers.sort()
|
||||||
|
|
||||||
|
start_pos = end_pos = start_end = end_end = None
|
||||||
|
for pos, is_start, end in markers:
|
||||||
|
if is_start and start_pos is None:
|
||||||
|
start_pos = pos
|
||||||
|
start_end = end
|
||||||
|
elif not is_start and start_pos is not None:
|
||||||
|
end_pos = pos
|
||||||
|
end_end = end
|
||||||
|
break
|
||||||
|
if start_pos is None or end_pos is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Определяем структурный элемент (параграф или строка таблицы)
|
||||||
|
s_start, s_end = _find_element_bounds(body_str, start_pos)
|
||||||
|
e_start, e_end = _find_element_bounds(body_str, end_pos)
|
||||||
|
|
||||||
|
if s_start is None or e_end is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Извлекаем шаблон (содержимое между маркерами)
|
||||||
|
template_xml = body_str[start_end:end_pos]
|
||||||
|
# Убираем остатки маркеров
|
||||||
|
template_xml = LOOP_START.sub('', template_xml)
|
||||||
|
template_xml = LOOP_END.sub('', template_xml)
|
||||||
|
|
||||||
|
# Размножаем
|
||||||
|
expanded = []
|
||||||
|
for idx, item in enumerate(items, 1):
|
||||||
|
clone = template_xml
|
||||||
|
for field, value in item.items():
|
||||||
|
clone = clone.replace(f'{{{{item.{field}}}}}', str(value) if value else EMPTY_MARKER)
|
||||||
|
clone = clone.replace(f'{{{{{field}}}}}', str(value) if value else EMPTY_MARKER)
|
||||||
|
clone = clone.replace('{{item.number}}', str(idx))
|
||||||
|
clone = clone.replace('{{number}}', str(idx))
|
||||||
|
clone = clone.replace('\u27f5BR\u27f5', '</w:t><w:br/><w:t xml:space="preserve">')
|
||||||
|
expanded.append(clone)
|
||||||
|
|
||||||
|
new_str = body_str[:s_start] + ''.join(expanded) + body_str[e_end:]
|
||||||
|
try:
|
||||||
|
new_body = etree.fromstring(new_str.encode('utf-8'))
|
||||||
|
parent = body.getparent()
|
||||||
|
if parent is not None:
|
||||||
|
parent.replace(body, new_body)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_loop_block(body, key: str):
|
||||||
|
body_str = etree.tostring(body, encoding='unicode')
|
||||||
|
start_pos = None
|
||||||
|
for m in LOOP_START.finditer(body_str):
|
||||||
|
if m.group(1) == key:
|
||||||
|
start_pos = m.start()
|
||||||
|
break
|
||||||
|
if start_pos is None:
|
||||||
|
return
|
||||||
|
s_start, s_end = _find_element_bounds(body_str, start_pos)
|
||||||
|
end_match = LOOP_END.search(body_str, start_pos)
|
||||||
|
if end_match:
|
||||||
|
_, e_end = _find_element_bounds(body_str, end_match.start())
|
||||||
|
new_str = body_str[:s_start] + body_str[e_end:]
|
||||||
|
else:
|
||||||
|
new_str = body_str[:s_start] + body_str[s_end:]
|
||||||
|
try:
|
||||||
|
new_body = etree.fromstring(new_str.encode('utf-8'))
|
||||||
|
parent = body.getparent()
|
||||||
|
if parent is not None:
|
||||||
|
parent.replace(body, new_body)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _find_element_bounds(xml_str: str, pos: int):
|
||||||
|
"""Находит границы элемента (table row или paragraph), содержащего позицию."""
|
||||||
|
# Пробуем tr
|
||||||
|
tr_open = max(xml_str.rfind('<w:tr ', 0, pos), xml_str.rfind('<w:tr>', 0, pos))
|
||||||
|
if tr_open != -1:
|
||||||
|
tr_close = xml_str.find('</w:tr>', pos)
|
||||||
|
if tr_close != -1:
|
||||||
|
return tr_open, tr_close + len('</w:tr>')
|
||||||
|
# Пробуем p
|
||||||
|
p_open = max(xml_str.rfind('<w:p ', 0, pos), xml_str.rfind('<w:p>', 0, pos))
|
||||||
|
if p_open != -1:
|
||||||
|
p_close = xml_str.find('</w:p>', pos)
|
||||||
|
if p_close != -1:
|
||||||
|
return p_open, p_close + len('</w:p>')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== ПОДСВЕТКА НЕЗАПОЛНЕННЫХ ====================
|
||||||
|
|
||||||
|
def _highlight_unmatched(doc):
|
||||||
|
"""Подсвечивает незаполненные переменные красным."""
|
||||||
|
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
|
||||||
|
def _highlight_run(run):
|
||||||
|
rpr = run._element.find(f'{{{ns}}}rPr')
|
||||||
|
if rpr is None:
|
||||||
|
rpr = OxmlElement('w:rPr')
|
||||||
|
run._element.insert(0, rpr)
|
||||||
|
color = OxmlElement('w:color')
|
||||||
|
color.set(f'{{{ns}}}val', 'FF0000')
|
||||||
|
rpr.append(color)
|
||||||
|
sz = OxmlElement('w:sz')
|
||||||
|
sz.set(f'{{{ns}}}val', '22')
|
||||||
|
rpr.append(sz)
|
||||||
|
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
for run in para.runs:
|
||||||
|
if '{{' in run.text and '}}' in run.text:
|
||||||
|
_highlight_run(run)
|
||||||
|
|
||||||
|
for table in doc.tables:
|
||||||
|
for row in table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
for para in cell.paragraphs:
|
||||||
|
for run in para.runs:
|
||||||
|
if '{{' in run.text and '}}' in run.text:
|
||||||
|
_highlight_run(run)
|
||||||
|
|
||||||
|
for section in doc.sections:
|
||||||
|
for hf in [section.header, section.footer]:
|
||||||
|
if hf:
|
||||||
|
for para in hf.paragraphs:
|
||||||
|
for run in para.runs:
|
||||||
|
if '{{' in run.text and '}}' in run.text:
|
||||||
|
_highlight_run(run)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# -*- 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 = ""
|
||||||
|
software: 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 = ""
|
||||||
|
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)
|
||||||
|
|
||||||
|
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"]
|
||||||
|
]
|
||||||
|
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 "Новая_организация"
|
||||||
+1052
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
|||||||
|
# -*- 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'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Маппинг старых переменных <...> → {{...}}
|
||||||
|
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 '—',
|
||||||
|
'is_software_list': isys.software 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 '—',
|
||||||
|
'software': isys.software or '—',
|
||||||
|
'is_has_lan': 'Да' if isys.is_local_network else 'Нет',
|
||||||
|
'has_internet': 'да' if isys.is_internet else 'нет',
|
||||||
|
'defence_level': str(isys.defence_level or '—'),
|
||||||
|
'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)
|
||||||
|
|
||||||
|
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