fix: правильная структура + main.py в корне

- Пакет dokogen/ в подпапке
- main.py и requirements.txt в корне
- Импорт: from dokogen.ui import main
This commit is contained in:
2026-07-30 13:52:18 +04:00
commit 5315d92b59
10 changed files with 2168 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
*.pyc
__pycache__/
.autosave/
Сгенерированные_документы/
*.result.docx
+10
View File
@@ -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__ = "ДоКоГеНеРаТоР"
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""DokoGen — Точка входа"""
import sys
import os
# Добавляем путь к пакету
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ui import main
if __name__ == "__main__":
main()
+222
View File
@@ -0,0 +1,222 @@
# -*- coding: utf-8 -*-
"""DokoGen — Склонение (pymorphy3 + правила для ФИО/организаций)"""
from functools import lru_cache
from typing import Dict
import re
try:
import pymorphy3
morph = pymorphy3.MorphAnalyzer()
MORPH_AVAILABLE = True
except ImportError:
morph = None
MORPH_AVAILABLE = False
CASES = ["nomn", "gent", "datv", "accs", "ablt", "loct"]
CASE_MAP = {
"nominative": "nomn", "им": "nomn",
"genitive": "gent", "род": "gent",
"dative": "datv", "дат": "datv",
"accusative": "accs", "вин": "accs",
"instrumental": "ablt", "твор": "ablt",
"prepositional": "loct", "пр": "loct",
}
CASE_NAMES = {
"nomn": "Именительный (Кто? Что?)",
"gent": "Родительный (Кого? Чего?)",
"datv": "Дательный (Кому? Чему?)",
"accs": "Винительный (Кого? Что?)",
"ablt": "Творительный (Кем? Чем?)",
"loct": "Предложный (О ком? О чём?)",
}
CASE_SHORT = {
"nomn": "им.п.", "gent": "род.п.", "datv": "дат.п.",
"accs": "вин.п.", "ablt": "твор.п.", "loct": "пр.п.",
}
def _normalize_case(case: str) -> str:
return CASE_MAP.get(case, "nomn" if case not in CASES else case)
def inflect_name(name: str) -> Dict[str, str]:
"""Склонение названия организации по всем падежам."""
return {gr: company_name_decline(name, gr) for gr in CASES}
@lru_cache(maxsize=512)
def company_name_decline(name: str, case: str) -> str:
"""Склонение названия организации."""
if not name:
return ""
case_gr = _normalize_case(case)
if case_gr == "nomn":
return name
m = re.match(r'^(.+?)\s*([\u00ab\"].*[\u00bb\"])$', name.strip())
if m:
prefix = m.group(1).strip()
quoted = m.group(2)
if prefix:
return decline(prefix, case_gr, decline_all=True) + " " + quoted
return name
return decline(name, case_gr, decline_all=True)
@lru_cache(maxsize=512)
def decline(text: str, case: str, decline_all: bool = False) -> str:
"""Склонение строки (ФИО, фразы) по падежам."""
if not text:
return ""
case_gr = _normalize_case(case)
if case_gr == "nomn":
return text
clean_text, quoted = _extract_quoted(text)
tokens = re.split(r"(\s+)", clean_text)
content_tokens = [t for t in tokens if not t.isspace() and t.strip()]
only_first = False
if not decline_all and len(content_tokens) >= 2:
for ct in content_tokens[1:]:
if ct[0].islower():
only_first = True
break
result_parts = []
content_idx = 0
for token in tokens:
if token.isspace() or not token.strip():
result_parts.append(token)
continue
stripped = token.strip()
if only_first and content_idx > 0:
result_parts.append(token)
content_idx += 1
continue
if _is_quoted(stripped):
result_parts.append(token)
content_idx += 1
continue
if stripped.isupper() and len(stripped) > 1:
result_parts.append(token)
content_idx += 1
continue
inflected = _inflect_word(stripped, case_gr)
if stripped[0].isupper():
inflected = inflected[0].upper() + inflected[1:]
result_parts.append(inflected)
content_idx += 1
return _restore_quoted("".join(result_parts), quoted)
def _inflect_word(word: str, case_gr: str) -> str:
"""Склонение одного слова через pymorphy3 с fallback на ручные правила."""
if not word or len(word) < 2 or case_gr == "nomn":
return word
if not MORPH_AVAILABLE or morph is None:
return _rules_decline(word, case_gr) or word
try:
parsed = morph.parse(word)
if not parsed:
return _rules_decline(word, case_gr) or word
best = parsed[0]
inflected = best.inflect({case_gr})
if inflected:
return inflected.word
return _rules_decline(word, case_gr) or word
except Exception:
return _rules_decline(word, case_gr) or word
def _rules_decline(word: str, case_gr: str) -> str:
"""Ручное склонение фамилий."""
w = word.lower()
# Мужские фамилии на -ов/-ев/-ёв
if w.endswith(('ов', 'ев', 'ёв')) and not w.endswith(('ова', 'ева', 'ёва')):
return {
'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а',
'ablt': word + 'ым', 'loct': word + 'е'
}.get(case_gr, word)
# Мужские фамилии на -ин
if w.endswith('ин') and not w.endswith('ина'):
return {
'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а',
'ablt': word + 'ым', 'loct': word + 'е'
}.get(case_gr, word)
# Женские фамилии на -ова/-ева/-ёва
if w.endswith(('ова', 'ева', 'ёва')):
stem = word[:-1]
return {
'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у',
'ablt': stem + 'ой', 'loct': stem + 'ой'
}.get(case_gr, word)
# Женские фамилии на -ина
if w.endswith('ина'):
stem = word[:-1]
return {
'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у',
'ablt': stem + 'ой', 'loct': stem + 'ой'
}.get(case_gr, word)
return word
def _extract_quoted(text: str):
"""Извлекает подстроки в кавычках, заменяет плейсхолдерами."""
if not text:
return text, {}
pattern = re.compile(r'(\u00ab[^\u00bb]*\u00bb|"[^"]*")')
placeholders = {}
counter = [0]
def _replace(m):
ph = f'__Q_{counter[0]}__'
placeholders[ph] = m.group(0)
counter[0] += 1
return ph
return pattern.sub(_replace, text), placeholders
def _restore_quoted(text: str, placeholders: dict) -> str:
for ph, original in placeholders.items():
text = text.replace(ph, original)
return text
def _is_quoted(word: str) -> bool:
w = word.strip()
if not w:
return False
return (w.startswith("\u00ab") and w.endswith("\u00bb")) or \
(w.startswith('"') and w.endswith('"'))
# ==================== ФОРМАТИРОВАНИЕ ФИО ====================
def get_short_fio(fio: str) -> str:
"""Фамилия И.О."""
if not fio:
return ""
parts = fio.split()
if len(parts) >= 2:
initials = parts[1][0] + "."
if len(parts) >= 3:
initials += parts[2][0] + "."
return parts[0] + " " + initials
return fio
def get_initials(fio: str) -> str:
"""И.О. Фамилия"""
if not fio:
return ""
parts = fio.split()
if len(parts) >= 2:
initials = parts[1][0] + "."
if len(parts) >= 3:
initials += parts[2][0] + "."
return initials + " " + parts[0]
return fio
+378
View File
@@ -0,0 +1,378 @@
# -*- coding: utf-8 -*-
"""DokoGen — Генератор документов из шаблонов DOCX"""
import os
import re
import io
from datetime import datetime
from typing import Dict, List, Optional, Callable
try:
from docx import Document
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
from lxml import etree
except ImportError:
Document = None
from .variables import OLD_VAR_MAP, OLD_VAR_PATTERN, VAR_PATTERN
# Стандартный шаблон для незаполненных переменных
EMPTY_MARKER = ''
def process_template(
template_path: str,
replacements: Dict[str, str],
output_path: Optional[str] = None,
log_func: Optional[Callable] = None,
) -> str:
"""Обрабатывает шаблон DOCX: подстановка переменных, циклы, сохранение."""
if Document is None:
raise ImportError("python-docx не установлен. Установите: pip install python-docx")
if not os.path.exists(template_path):
raise FileNotFoundError(f"Шаблон не найден: {template_path}")
if output_path is None:
base, ext = os.path.splitext(template_path)
output_path = f"{base}_result{ext}"
doc = Document(template_path)
# 1. Миграция старых переменных <...> → {{...}}
_migrate_old_vars(doc)
# 2. Удаление proofErr
_strip_prooferr(doc)
# 3. Обработка циклов (loop/loop_end)
loops = replacements.pop('_loops', {})
if loops:
_process_loops(doc, loops)
# 4. Перезагрузка после циклов
buf = io.BytesIO()
doc.save(buf)
buf.seek(0)
doc = Document(buf)
# 5. Плоская замена переменных (2 прохода)
for _ in range(2):
_replace_in_paragraphs(doc.paragraphs, replacements)
_replace_in_tables(doc.tables, replacements)
_replace_in_headers_footers(doc, replacements)
# 6. Подсветка незаполненных
_highlight_unmatched(doc)
# 7. Сохранение
doc.save(output_path)
if log_func:
log_func(f"{os.path.basename(output_path)}")
return output_path
# ==================== МИГРАЦИЯ СТАРЫХ ПЕРЕМЕННЫХ ====================
def _migrate_old_vars(doc):
"""Заменяет <OldVar> на {{new_var}} в документе."""
def _migrate_text(text):
return OLD_VAR_PATTERN.sub(lambda m: OLD_VAR_MAP.get(m.group(0), m.group(0)), text)
for para in doc.paragraphs:
new_text = _migrate_text(para.text)
if new_text != para.text:
if para.runs:
first = para.runs[0]
para.clear()
run = para.add_run(new_text)
_copy_run_style(first, run)
else:
para.clear()
para.add_run(new_text)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
new_text = _migrate_text(para.text)
if new_text != para.text:
if para.runs:
first = para.runs[0]
para.clear()
run = para.add_run(new_text)
_copy_run_style(first, run)
else:
para.clear()
para.add_run(new_text)
for section in doc.sections:
for hf in [section.header, section.footer,
section.first_page_header, section.first_page_footer]:
if hf:
for para in hf.paragraphs:
new_text = _migrate_text(para.text)
if new_text != para.text:
if para.runs:
first = para.runs[0]
para.clear()
run = para.add_run(new_text)
_copy_run_style(first, run)
else:
para.clear()
para.add_run(new_text)
# ==================== ЗАМЕНА В ЭЛЕМЕНТАХ ====================
def _replace_in_paragraphs(paragraphs, replacements):
for para in paragraphs:
full = para.text
# Пропускаем строки циклов
if 'loop:' in full or 'loop_end' in full:
continue
matches = VAR_PATTERN.findall(full)
if not matches:
continue
new_text = full
for var in matches:
val = replacements.get(var)
if val is not None:
new_text = new_text.replace(var, str(val))
if new_text != full:
if para.runs:
first = para.runs[0]
para.clear()
run = para.add_run(new_text)
_copy_run_style(first, run)
else:
para.clear()
para.add_run(new_text)
def _replace_in_tables(tables, replacements):
for table in tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
full = para.text
matches = VAR_PATTERN.findall(full)
if not matches:
continue
new_text = full
for var in matches:
val = replacements.get(var)
if val is not None:
new_text = new_text.replace(var, str(val))
if new_text != full:
if para.runs:
first = para.runs[0]
para.clear()
run = para.add_run(new_text)
_copy_run_style(first, run)
else:
para.clear()
para.add_run(new_text)
def _replace_in_headers_footers(doc, replacements):
for section in doc.sections:
for hf in [section.header, section.footer,
section.first_page_header, section.first_page_footer]:
if hf:
_replace_in_paragraphs(hf.paragraphs, replacements)
def _copy_run_style(source, target):
"""Копирует форматирование из одного run в другой."""
try:
target.bold = source.bold
target.italic = source.italic
target.underline = source.underline
if source.font:
target.font.size = source.font.size
target.font.name = source.font.name
except Exception:
pass
def _strip_prooferr(doc):
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
for elem in doc.element.iter():
for child in list(elem):
if child.tag == f'{{{ns}}}proofErr':
elem.remove(child)
# ==================== ОБРАБОТКА ЦИКЛОВ ====================
LOOP_START = re.compile(r'(?:&lt;!--|<!--)?\s*loop:(\w+)(?:\s*(?:--&gt;|-->))?')
LOOP_END = re.compile(r'(?:&lt;!--|<!--)?\s*loop_end(?:\s*(?:--&gt;|-->))?')
def _process_loops(doc, loops: Dict[str, List[Dict]]):
body = doc.element.body
max_passes = 20
for _ in range(max_passes):
body_str = etree.tostring(body, encoding='unicode')
# Находим все ключи циклов
found = set(LOOP_START.findall(body_str))
if not found:
break
changed = False
for key in sorted(found):
items = loops.get(key, [])
if not items:
_remove_loop_block(body, key)
changed = True
else:
_expand_loop(body, key, items)
changed = True
body = doc.element.body
if not changed:
break
def _expand_loop(body, key: str, items: List[Dict]):
body_str = etree.tostring(body, encoding='unicode')
markers = []
for m in LOOP_START.finditer(body_str):
if m.group(1) == key:
markers.append((m.start(), True, m.end()))
for m in LOOP_END.finditer(body_str):
markers.append((m.start(), False, m.end()))
if len(markers) < 2:
return
markers.sort()
start_pos = end_pos = start_end = end_end = None
for pos, is_start, end in markers:
if is_start and start_pos is None:
start_pos = pos
start_end = end
elif not is_start and start_pos is not None:
end_pos = pos
end_end = end
break
if start_pos is None or end_pos is None:
return
# Определяем структурный элемент (параграф или строка таблицы)
s_start, s_end = _find_element_bounds(body_str, start_pos)
e_start, e_end = _find_element_bounds(body_str, end_pos)
if s_start is None or e_end is None:
return
# Извлекаем шаблон (содержимое между маркерами)
template_xml = body_str[start_end:end_pos]
# Убираем остатки маркеров
template_xml = LOOP_START.sub('', template_xml)
template_xml = LOOP_END.sub('', template_xml)
# Размножаем
expanded = []
for idx, item in enumerate(items, 1):
clone = template_xml
for field, value in item.items():
clone = clone.replace(f'{{{{item.{field}}}}}', str(value) if value else EMPTY_MARKER)
clone = clone.replace(f'{{{{{field}}}}}', str(value) if value else EMPTY_MARKER)
clone = clone.replace('{{item.number}}', str(idx))
clone = clone.replace('{{number}}', str(idx))
clone = clone.replace('\u27f5BR\u27f5', '</w:t><w:br/><w:t xml:space="preserve">')
expanded.append(clone)
new_str = body_str[:s_start] + ''.join(expanded) + body_str[e_end:]
try:
new_body = etree.fromstring(new_str.encode('utf-8'))
parent = body.getparent()
if parent is not None:
parent.replace(body, new_body)
except Exception:
pass
def _remove_loop_block(body, key: str):
body_str = etree.tostring(body, encoding='unicode')
start_pos = None
for m in LOOP_START.finditer(body_str):
if m.group(1) == key:
start_pos = m.start()
break
if start_pos is None:
return
s_start, s_end = _find_element_bounds(body_str, start_pos)
end_match = LOOP_END.search(body_str, start_pos)
if end_match:
_, e_end = _find_element_bounds(body_str, end_match.start())
new_str = body_str[:s_start] + body_str[e_end:]
else:
new_str = body_str[:s_start] + body_str[s_end:]
try:
new_body = etree.fromstring(new_str.encode('utf-8'))
parent = body.getparent()
if parent is not None:
parent.replace(body, new_body)
except Exception:
pass
def _find_element_bounds(xml_str: str, pos: int):
"""Находит границы элемента (table row или paragraph), содержащего позицию."""
# Пробуем tr
tr_open = max(xml_str.rfind('<w:tr ', 0, pos), xml_str.rfind('<w:tr>', 0, pos))
if tr_open != -1:
tr_close = xml_str.find('</w:tr>', pos)
if tr_close != -1:
return tr_open, tr_close + len('</w:tr>')
# Пробуем p
p_open = max(xml_str.rfind('<w:p ', 0, pos), xml_str.rfind('<w:p>', 0, pos))
if p_open != -1:
p_close = xml_str.find('</w:p>', pos)
if p_close != -1:
return p_open, p_close + len('</w:p>')
return None, None
# ==================== ПОДСВЕТКА НЕЗАПОЛНЕННЫХ ====================
def _highlight_unmatched(doc):
"""Подсвечивает незаполненные переменные красным."""
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def _highlight_run(run):
rpr = run._element.find(f'{{{ns}}}rPr')
if rpr is None:
rpr = OxmlElement('w:rPr')
run._element.insert(0, rpr)
color = OxmlElement('w:color')
color.set(f'{{{ns}}}val', 'FF0000')
rpr.append(color)
sz = OxmlElement('w:sz')
sz.set(f'{{{ns}}}val', '22')
rpr.append(sz)
for para in doc.paragraphs:
for run in para.runs:
if '{{' in run.text and '}}' in run.text:
_highlight_run(run)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
for run in para.runs:
if '{{' in run.text and '}}' in run.text:
_highlight_run(run)
for section in doc.sections:
for hf in [section.header, section.footer]:
if hf:
for para in hf.paragraphs:
for run in para.runs:
if '{{' in run.text and '}}' in run.text:
_highlight_run(run)
+144
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+327
View File
@@ -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'])
+12
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
python-docx>=1.1.0
pymorphy3>=2.0.0
openpyxl>=3.1.0
lxml>=5.0.0